[
  {
    "path": ".dockerignore",
    "content": "#\n# Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n#\n# This code is free software; you can redistribute it and/or modify it\n# under the terms of the GNU General Public License version 2 only, as\n# published by the Free Software Foundation.\n#\n# This code is distributed in the hope that it will be useful, but WITHOUT\n# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n# version 2 for more details (a copy is included in the LICENSE file that\n# accompanied this code).\n#\n# You should have received a copy of the GNU General Public License version\n# 2 along with this work; if not, write to the Free Software Foundation,\n# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n#\n# Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n# or visit www.oracle.com if you need additional information or have any\n# questions.\n#\n**/.*\n**/build/\n**/out/\n**/bin/\n"
  },
  {
    "path": ".gitattributes",
    "content": "#\n# Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n#\n# This code is free software; you can redistribute it and/or modify it\n# under the terms of the GNU General Public License version 2 only, as\n# published by the Free Software Foundation.\n#\n# This code is distributed in the hope that it will be useful, but WITHOUT\n# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n# version 2 for more details (a copy is included in the LICENSE file that\n# accompanied this code).\n#\n# You should have received a copy of the GNU General Public License version\n# 2 along with this work; if not, write to the Free Software Foundation,\n# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n#\n# Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n# or visit www.oracle.com if you need additional information or have any\n# questions.\n#\ngradlew.bat text eol=crlf\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "\n\n\n---------\n- [ ] I confirm that I make this contribution in accordance with the [OpenJDK Interim AI Policy](https://openjdk.org/legal/ai).\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "#\n# The pre-submit tests will only runs for forks of the TARGET_PROJECT defined below. This is set to \"skara\" by default,\n# and can be changed by downstream projects if they also want to run pre-submit tests.\n#\n# The tests will attempt to merge the latest commits from TARGET_BRANCH before executing, to ensure that what is tested\n# is as close as possible to what the final integration result will be. This is set to \"master\" by default, and can\n# be changed by downstream projects that utilize multiple branches in order to select the correct one.\n#\nname: Pre-submit tests\n\non:\n  push:\n    branches-ignore:\n      - master\n      - pr/*\n\njobs:\n  prerequisites:\n    name: Prerequisites\n    runs-on: \"ubuntu-latest\"\n    env:\n      TARGET_PROJECT: skara\n      TARGET_BRANCH: master\n    outputs:\n      should_run: ${{ steps.check_submit.outputs.should_run }}\n      fetch_target_command: ${{ steps.merge_target.outputs.command }}\n      merge_target_command: ${{ steps.try_merge_target.outputs.command }}\n\n    steps:\n      - name: Determine target project name (fork source)\n        id: upstream_repo\n        uses: actions/github-script@v7\n        with:\n          result-encoding: string\n          script: \"return (await github.rest.repos.get( {owner: context.repo.owner, repo: context.repo.repo })).data.source.name\"\n\n      - name: Check if submit tests should actually run\n        id: check_submit\n        run: echo \"should_run=${{ env.TARGET_PROJECT == steps.upstream_repo.outputs.result }}\" >> $GITHUB_OUTPUT\n\n      - name: Checkout the source\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 1000\n        if: steps.check_submit.outputs.should_run != 'false'\n\n      - name: Determine merge target hash\n        id: merge_target\n        run: |\n          git fetch https://github.com/openjdk/${{ steps.upstream_repo.outputs.result }} ${TARGET_BRANCH}\n          echo \"hash=`git rev-parse FETCH_HEAD`\" >> $GITHUB_OUTPUT\n          echo \"command=git fetch https://github.com/openjdk/${{ steps.upstream_repo.outputs.result }} ${TARGET_BRANCH}\" >> $GITHUB_OUTPUT\n        if: steps.check_submit.outputs.should_run != 'false'\n\n      - name: Determine merge strategy\n        id: try_merge_target\n        run: >\n          (git -c user.name=\"presubmit\" -c user.email=\"presubmit@github.actions\" merge --no-edit ${{ steps.merge_target.outputs.hash }} &&\n            (echo \"command=git -c user.name=\"presubmit\" -c user.email=\"presubmit@github.actions\" merge --no-edit ${{ steps.merge_target.outputs.hash }}\") >> $GITHUB_OUTPUT) ||\n          (git merge --abort && git -c user.name=\"presubmit\" -c user.email=\"presubmit@github.actions\" rebase ${{ steps.merge_target.outputs.hash }} &&\n            (echo \"command=git -c user.name=\"presubmit\" -c user.email=\"presubmit@github.actions\" rebase ${{ steps.merge_target.outputs.hash }}\") >> $GITHUB_OUTPUT) ||\n          (echo \"command=echo There are merge conflicts with the target that will have to be resolved before integration\" >> $GITHUB_OUTPUT)\n\n  linux:\n    name: Linux x64\n    runs-on: \"ubuntu-22.04\"\n    needs: prerequisites\n    if: needs.prerequisites.outputs.should_run\n\n    steps:\n      - name: Checkout the source\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 1000\n\n      - name: Merge latest changes from target branch\n        run: |\n          ${{ needs.prerequisites.outputs.fetch_target_command }}\n          ${{ needs.prerequisites.outputs.merge_target_command }}\n\n      - name: Build and test\n        run: sh gradlew test local --info --stacktrace\n\n  mac:\n    name: macOS x64\n    runs-on: \"macos-14\"\n    needs: prerequisites\n\n    steps:\n      - name: Checkout the source\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 1000\n\n      - name: Merge latest changes from target branch\n        run: |\n          ${{ needs.prerequisites.outputs.fetch_target_command }}\n          ${{ needs.prerequisites.outputs.merge_target_command }}\n\n      - name: Install Mercurial\n        run: brew install mercurial\n\n      - name: Build and test\n        run: sh gradlew test local --info --stacktrace\n\n  win:\n    name: Windows x64\n    runs-on: \"windows-2025\"\n    needs: prerequisites\n\n    steps:\n      - name: Checkout the source\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 1000\n\n      - name: Merge latest changes from target branch\n        run: |\n          ${{ needs.prerequisites.outputs.fetch_target_command }}\n          ${{ needs.prerequisites.outputs.merge_target_command }}\n\n      - name: Build and test\n        run: gradlew.bat test local --info --stacktrace\n        shell: cmd\n"
  },
  {
    "path": ".gitignore",
    "content": "#\n# Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n#\n# This code is free software; you can redistribute it and/or modify it\n# under the terms of the GNU General Public License version 2 only, as\n# published by the Free Software Foundation.\n#\n# This code is distributed in the hope that it will be useful, but WITHOUT\n# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n# version 2 for more details (a copy is included in the LICENSE file that\n# accompanied this code).\n#\n# You should have received a copy of the GNU General Public License version\n# 2 along with this work; if not, write to the Free Software Foundation,\n# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n#\n# Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n# or visit www.oracle.com if you need additional information or have any\n# questions.\n#\n.gradle\n.jdk\n.jib\n.classpath\n.idea\n.project\n.settings\n*.iml\nbuild/\nout/\nbin/\ntest.properties\n"
  },
  {
    "path": ".jcheck/conf",
    "content": ";\n; Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n; DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n;\n; This code is free software; you can redistribute it and/or modify it\n; under the terms of the GNU General Public License version 2 only, as\n; published by the Free Software Foundation.\n;\n; This code is distributed in the hope that it will be useful, but WITHOUT\n; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n; FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n; version 2 for more details (a copy is included in the LICENSE file that\n; accompanied this code).\n;\n; You should have received a copy of the GNU General Public License version\n; 2 along with this work; if not, write to the Free Software Foundation,\n; Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n;\n; Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n; or visit www.oracle.com if you need additional information or have any\n; questions.\n;\n\n[general]\nproject=skara\nrepository=skara\njbs=skara\nversion=1.0\n\n[checks]\nerror=author,reviewers,whitespace\nwarning=copyright\n\n[census]\nversion=0\ndomain=openjdk.org\n\n[checks \"whitespace\"]\nfiles=.*\\.java$|.*\\.yml$|.*\\.gradle$|.*.\\txt$\n\n[checks \"reviewers\"]\nreviewers=1\n\n[checks \"copyright\"]\nfiles=.*\\.java|.*\\.gradle|.*\\.sh|.*\\.bat|.*\\.py|.*\\.css|.*\\.html|.*\\.dockerfile|.*\\.gitconfig|Makefile\noracle_locator=.*Copyright \\(c\\)(.*)Oracle and/or its affiliates\\. All rights reserved\\.\noracle_validator=.*Copyright \\(c\\) (\\d{4})(?:, (\\d{4}))?, Oracle and/or its affiliates\\. All rights reserved\\.\noracle_required=true\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nThank you for considering contributing to project\n[Skara](https://openjdk.org/projects/skara)! For information about\ncontributing to [OpenJDK](https://openjdk.org/) projects, which include\nSkara, please see <https://openjdk.org/contribute/>.\n\n## Mailing List\n\nProject Skara happily accept contributions in the forms of patches sent to\nour mailing list, `skara-dev@openjdk.org`. See\n<https://mail.openjdk.org/mailman/listinfo/skara-dev> for instructions\non how to subscribe of if you want to read the archives\n\n## Pull Requests\n\nProject Skara also gladly accepts contributions in the form of pull requests\non [GitHub](https://github.com/openjdk/skara/pulls/).\n\n## Issues\n\nYou can find open issues to work on in the Skara project in the\n[JDK Bug System](https://bugs.openjdk.org/):\n<https://bugs.openjdk.org/projects/SKARA>.\n\n## Larger Contributions\n\nIf you have a larger contribution in mind then we highly encourage you to first\ndiscuss your changes on the Skara mailing list, `skara-dev@openjdk.org`,\n_before_ you start to write the code.\n\n## Questions\n\nIf you have a question or need help, please send an email to our mailing list\n`skara-dev@openjdk.org` or stop by the IRC channel `#openjdk` on\n[OFTC](https://www.oftc.net/) (see <http://openjdk.org/irc/> for details).\n"
  },
  {
    "path": "LICENSE",
    "content": "The GNU General Public License (GPL)\n\nVersion 2, June 1991\n\nCopyright (C) 1989, 1991 Free Software Foundation, Inc.\n51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA\n\nEveryone is permitted to copy and distribute verbatim copies of this license\ndocument, but changing it is not allowed.\n\nPreamble\n\nThe licenses for most software are designed to take away your freedom to share\nand change it.  By contrast, the GNU General Public License is intended to\nguarantee your freedom to share and change free software--to make sure the\nsoftware is free for all its users.  This General Public License applies to\nmost of the Free Software Foundation's software and to any other program whose\nauthors commit to using it.  (Some other Free Software Foundation software is\ncovered by the GNU Library General Public License instead.) You can apply it to\nyour programs, too.\n\nWhen we speak of free software, we are referring to freedom, not price.  Our\nGeneral Public Licenses are designed to make sure that you have the freedom to\ndistribute copies of free software (and charge for this service if you wish),\nthat you receive source code or can get it if you want it, that you can change\nthe software or use pieces of it in new free programs; and that you know you\ncan do these things.\n\nTo protect your rights, we need to make restrictions that forbid anyone to deny\nyou these rights or to ask you to surrender the rights.  These restrictions\ntranslate to certain responsibilities for you if you distribute copies of the\nsoftware, or if you modify it.\n\nFor example, if you distribute copies of such a program, whether gratis or for\na fee, you must give the recipients all the rights that you have.  You must\nmake sure that they, too, receive or can get the source code.  And you must\nshow them these terms so they know their rights.\n\nWe protect your rights with two steps: (1) copyright the software, and (2)\noffer you this license which gives you legal permission to copy, distribute\nand/or modify the software.\n\nAlso, for each author's protection and ours, we want to make certain that\neveryone understands that there is no warranty for this free software.  If the\nsoftware is modified by someone else and passed on, we want its recipients to\nknow that what they have is not the original, so that any problems introduced\nby others will not reflect on the original authors' reputations.\n\nFinally, any free program is threatened constantly by software patents.  We\nwish to avoid the danger that redistributors of a free program will\nindividually obtain patent licenses, in effect making the program proprietary.\nTo prevent this, we have made it clear that any patent must be licensed for\neveryone's free use or not licensed at all.\n\nThe precise terms and conditions for copying, distribution and modification\nfollow.\n\nTERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION\n\n0. This License applies to any program or other work which contains a notice\nplaced by the copyright holder saying it may be distributed under the terms of\nthis General Public License.  The \"Program\", below, refers to any such program\nor work, and a \"work based on the Program\" means either the Program or any\nderivative work under copyright law: that is to say, a work containing the\nProgram or a portion of it, either verbatim or with modifications and/or\ntranslated into another language.  (Hereinafter, translation is included\nwithout limitation in the term \"modification\".) Each licensee is addressed as\n\"you\".\n\nActivities other than copying, distribution and modification are not covered by\nthis License; they are outside its scope.  The act of running the Program is\nnot restricted, and the output from the Program is covered only if its contents\nconstitute a work based on the Program (independent of having been made by\nrunning the Program).  Whether that is true depends on what the Program does.\n\n1. You may copy and distribute verbatim copies of the Program's source code as\nyou receive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice and\ndisclaimer of warranty; keep intact all the notices that refer to this License\nand to the absence of any warranty; and give any other recipients of the\nProgram a copy of this License along with the Program.\n\nYou may charge a fee for the physical act of transferring a copy, and you may\nat your option offer warranty protection in exchange for a fee.\n\n2. You may modify your copy or copies of the Program or any portion of it, thus\nforming a work based on the Program, and copy and distribute such modifications\nor work under the terms of Section 1 above, provided that you also meet all of\nthese conditions:\n\n    a) You must cause the modified files to carry prominent notices stating\n    that you changed the files and the date of any change.\n\n    b) You must cause any work that you distribute or publish, that in whole or\n    in part contains or is derived from the Program or any part thereof, to be\n    licensed as a whole at no charge to all third parties under the terms of\n    this License.\n\n    c) If the modified program normally reads commands interactively when run,\n    you must cause it, when started running for such interactive use in the\n    most ordinary way, to print or display an announcement including an\n    appropriate copyright notice and a notice that there is no warranty (or\n    else, saying that you provide a warranty) and that users may redistribute\n    the program under these conditions, and telling the user how to view a copy\n    of this License.  (Exception: if the Program itself is interactive but does\n    not normally print such an announcement, your work based on the Program is\n    not required to print an announcement.)\n\nThese requirements apply to the modified work as a whole.  If identifiable\nsections of that work are not derived from the Program, and can be reasonably\nconsidered independent and separate works in themselves, then this License, and\nits terms, do not apply to those sections when you distribute them as separate\nworks.  But when you distribute the same sections as part of a whole which is a\nwork based on the Program, the distribution of the whole must be on the terms\nof this License, whose permissions for other licensees extend to the entire\nwhole, and thus to each and every part regardless of who wrote it.\n\nThus, it is not the intent of this section to claim rights or contest your\nrights to work written entirely by you; rather, the intent is to exercise the\nright to control the distribution of derivative or collective works based on\nthe Program.\n\nIn addition, mere aggregation of another work not based on the Program with the\nProgram (or with a work based on the Program) on a volume of a storage or\ndistribution medium does not bring the other work under the scope of this\nLicense.\n\n3. You may copy and distribute the Program (or a work based on it, under\nSection 2) in object code or executable form under the terms of Sections 1 and\n2 above provided that you also do one of the following:\n\n    a) Accompany it with the complete corresponding machine-readable source\n    code, which must be distributed under the terms of Sections 1 and 2 above\n    on a medium customarily used for software interchange; or,\n\n    b) Accompany it with a written offer, valid for at least three years, to\n    give any third party, for a charge no more than your cost of physically\n    performing source distribution, a complete machine-readable copy of the\n    corresponding source code, to be distributed under the terms of Sections 1\n    and 2 above on a medium customarily used for software interchange; or,\n\n    c) Accompany it with the information you received as to the offer to\n    distribute corresponding source code.  (This alternative is allowed only\n    for noncommercial distribution and only if you received the program in\n    object code or executable form with such an offer, in accord with\n    Subsection b above.)\n\nThe source code for a work means the preferred form of the work for making\nmodifications to it.  For an executable work, complete source code means all\nthe source code for all modules it contains, plus any associated interface\ndefinition files, plus the scripts used to control compilation and installation\nof the executable.  However, as a special exception, the source code\ndistributed need not include anything that is normally distributed (in either\nsource or binary form) with the major components (compiler, kernel, and so on)\nof the operating system on which the executable runs, unless that component\nitself accompanies the executable.\n\nIf distribution of executable or object code is made by offering access to copy\nfrom a designated place, then offering equivalent access to copy the source\ncode from the same place counts as distribution of the source code, even though\nthird parties are not compelled to copy the source along with the object code.\n\n4. You may not copy, modify, sublicense, or distribute the Program except as\nexpressly provided under this License.  Any attempt otherwise to copy, modify,\nsublicense or distribute the Program is void, and will automatically terminate\nyour rights under this License.  However, parties who have received copies, or\nrights, from you under this License will not have their licenses terminated so\nlong as such parties remain in full compliance.\n\n5. You are not required to accept this License, since you have not signed it.\nHowever, nothing else grants you permission to modify or distribute the Program\nor its derivative works.  These actions are prohibited by law if you do not\naccept this License.  Therefore, by modifying or distributing the Program (or\nany work based on the Program), you indicate your acceptance of this License to\ndo so, and all its terms and conditions for copying, distributing or modifying\nthe Program or works based on it.\n\n6. Each time you redistribute the Program (or any work based on the Program),\nthe recipient automatically receives a license from the original licensor to\ncopy, distribute or modify the Program subject to these terms and conditions.\nYou may not impose any further restrictions on the recipients' exercise of the\nrights granted herein.  You are not responsible for enforcing compliance by\nthird parties to this License.\n\n7. If, as a consequence of a court judgment or allegation of patent\ninfringement or for any other reason (not limited to patent issues), conditions\nare imposed on you (whether by court order, agreement or otherwise) that\ncontradict the conditions of this License, they do not excuse you from the\nconditions of this License.  If you cannot distribute so as to satisfy\nsimultaneously your obligations under this License and any other pertinent\nobligations, then as a consequence you may not distribute the Program at all.\nFor example, if a patent license would not permit royalty-free redistribution\nof the Program by all those who receive copies directly or indirectly through\nyou, then the only way you could satisfy both it and this License would be to\nrefrain entirely from distribution of the Program.\n\nIf any portion of this section is held invalid or unenforceable under any\nparticular circumstance, the balance of the section is intended to apply and\nthe section as a whole is intended to apply in other circumstances.\n\nIt is not the purpose of this section to induce you to infringe any patents or\nother property right claims or to contest validity of any such claims; this\nsection has the sole purpose of protecting the integrity of the free software\ndistribution system, which is implemented by public license practices.  Many\npeople have made generous contributions to the wide range of software\ndistributed through that system in reliance on consistent application of that\nsystem; it is up to the author/donor to decide if he or she is willing to\ndistribute software through any other system and a licensee cannot impose that\nchoice.\n\nThis section is intended to make thoroughly clear what is believed to be a\nconsequence of the rest of this License.\n\n8. If the distribution and/or use of the Program is restricted in certain\ncountries either by patents or by copyrighted interfaces, the original\ncopyright holder who places the Program under this License may add an explicit\ngeographical distribution limitation excluding those countries, so that\ndistribution is permitted only in or among countries not thus excluded.  In\nsuch case, this License incorporates the limitation as if written in the body\nof this License.\n\n9. The Free Software Foundation may publish revised and/or new versions of the\nGeneral Public License from time to time.  Such new versions will be similar in\nspirit to the present version, but may differ in detail to address new problems\nor concerns.\n\nEach version is given a distinguishing version number.  If the Program\nspecifies a version number of this License which applies to it and \"any later\nversion\", you have the option of following the terms and conditions either of\nthat version or of any later version published by the Free Software Foundation.\nIf the Program does not specify a version number of this License, you may\nchoose any version ever published by the Free Software Foundation.\n\n10. If you wish to incorporate parts of the Program into other free programs\nwhose distribution conditions are different, write to the author to ask for\npermission.  For software which is copyrighted by the Free Software Foundation,\nwrite to the Free Software Foundation; we sometimes make exceptions for this.\nOur decision will be guided by the two goals of preserving the free status of\nall derivatives of our free software and of promoting the sharing and reuse of\nsoftware generally.\n\nNO WARRANTY\n\n11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR\nTHE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN OTHERWISE\nSTATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE\nPROGRAM \"AS IS\" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED,\nINCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND\nFITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND\nPERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE,\nYOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL\nANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE\nPROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR\nINABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA\nBEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A\nFAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER\nOR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.\n\nEND OF TERMS AND CONDITIONS\n\nHow to Apply These Terms to Your New Programs\n\nIf you develop a new program, and you want it to be of the greatest possible\nuse to the public, the best way to achieve this is to make it free software\nwhich everyone can redistribute and change under these terms.\n\nTo do so, attach the following notices to the program.  It is safest to attach\nthem to the start of each source file to most effectively convey the exclusion\nof warranty; and each file should have at least the \"copyright\" line and a\npointer to where the full notice is found.\n\n    One line to give the program's name and a brief idea of what it does.\n\n    Copyright (C) <year> <name of author>\n\n    This program is free software; you can redistribute it and/or modify it\n    under the terms of the GNU General Public License as published by the Free\n    Software Foundation; either version 2 of the License, or (at your option)\n    any later version.\n\n    This program is distributed in the hope that it will be useful, but WITHOUT\n    ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n    FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for\n    more details.\n\n    You should have received a copy of the GNU General Public License along\n    with this program; if not, write to the Free Software Foundation, Inc.,\n    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.\n\nAlso add information on how to contact you by electronic and paper mail.\n\nIf the program is interactive, make it output a short notice like this when it\nstarts in an interactive mode:\n\n    Gnomovision version 69, Copyright (C) year name of author Gnomovision comes\n    with ABSOLUTELY NO WARRANTY; for details type 'show w'.  This is free\n    software, and you are welcome to redistribute it under certain conditions;\n    type 'show c' for details.\n\nThe hypothetical commands 'show w' and 'show c' should show the appropriate\nparts of the General Public License.  Of course, the commands you use may be\ncalled something other than 'show w' and 'show c'; they could even be\nmouse-clicks or menu items--whatever suits your program.\n\nYou should also get your employer (if you work as a programmer) or your school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.  Here\nis a sample; alter the names:\n\n    Yoyodyne, Inc., hereby disclaims all copyright interest in the program\n    'Gnomovision' (which makes passes at compilers) written by James Hacker.\n\n    signature of Ty Coon, 1 April 1989\n\n    Ty Coon, President of Vice\n\nThis General Public License does not permit incorporating your program into\nproprietary programs.  If your program is a subroutine library, you may\nconsider it more useful to permit linking proprietary applications with the\nlibrary.  If this is what you want to do, use the GNU Library General Public\nLicense instead of this License.\n\n\n\"CLASSPATH\" EXCEPTION TO THE GPL\n\nCertain source files distributed by Oracle America and/or its affiliates are\nsubject to the following clarification and special exception to the GPL, but\nonly where Oracle has expressly included in the particular source file's header\nthe words \"Oracle designates this particular file as subject to the \"Classpath\"\nexception as provided by Oracle in the LICENSE file that accompanied this code.\"\n\n    Linking this library statically or dynamically with other modules is making\n    a combined work based on this library.  Thus, the terms and conditions of\n    the GNU General Public License cover the whole combination.\n\n    As a special exception, the copyright holders of this library give you\n    permission to link this library with independent modules to produce an\n    executable, regardless of the license terms of these independent modules,\n    and to copy and distribute the resulting executable under terms of your\n    choice, provided that you also meet, for each linked independent module,\n    the terms and conditions of the license of that module.  An independent\n    module is a module which is not derived from or based on this library.  If\n    you modify this library, you may extend this exception to your version of\n    the library, but you are not obligated to do so.  If you do not wish to do\n    so, delete this exception statement from your version.\n"
  },
  {
    "path": "Makefile",
    "content": "# Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n#\n# This code is free software; you can redistribute it and/or modify it\n# under the terms of the GNU General Public License version 2 only, as\n# published by the Free Software Foundation.\n#\n# This code is distributed in the hope that it will be useful, but WITHOUT\n# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n# version 2 for more details (a copy is included in the LICENSE file that\n# accompanied this code).\n#\n# You should have received a copy of the GNU General Public License version\n# 2 along with this work; if not, write to the Free Software Foundation,\n# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n#\n# Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n# or visit www.oracle.com if you need additional information or have any\n# questions.\n\nBUILD=build\nprefix=$(HOME)/.local\nbindir=$(prefix)/bin\nsharedir=$(prefix)/share\nmandir=$(prefix)/man\n\nLAUNCHERS=$(addprefix $(bindir)/,$(notdir $(wildcard $(BUILD)/bin/git-*)))\nMANPAGES=$(addprefix $(mandir)/man1/,$(notdir $(wildcard $(BUILD)/bin/man/man1/*)))\n\nall:\n\t@sh gradlew\n\ncheck:\n\t@sh gradlew test\n\ntest:\n\t@sh gradlew test\n\nclean:\n\t@sh gradlew clean\n\nimages:\n\t@sh gradlew images\n\nbots:\n\t@sh gradlew bots\n\noffline:\n\t@sh gradlew :offline\n\nreproduce:\n\t@sh gradlew :reproduce\n\ninstall: all $(LAUNCHERS) $(MANPAGES) $(sharedir)/skara\n\t@echo \"Successfully installed to $(prefix)\"\n\nuninstall:\n\t@rm -rf $(sharedir)/skara\n\t@rm $(LAUNCHERS)\n\t@rm $(MANPAGES)\n\n$(mandir)/man1/%: $(BUILD)/bin/man/man1/%\n\t@mkdir -p $(mandir)/man1\n\t@cp $< $@\n\n$(sharedir)/skara: $(BUILD)/image\n\t@mkdir -p $(sharedir)\n\t@rm -rf $@\n\t@cp -r $< $@\n\n$(bindir)/%: $(BUILD)/bin/%\n\t@mkdir -p $(bindir)\n\t@sed 's~export JAVA_HOME=.*$$~export JAVA_HOME\\=$(sharedir)\\/skara~' < $< > $@\n\t@chmod 755 $@\n\n.PHONY: all bots check clean images install test uninstall\n"
  },
  {
    "path": "README.md",
    "content": "# OpenJDK Project Skara\n\nThe goal of Project Skara is to investigate alternative SCM and code review\noptions for the OpenJDK source code, including options based upon Git rather than\nMercurial, and including options hosted by third parties.\n\nThis repository contains tooling for working with OpenJDK projects and\ntheir repositories. The following CLI tools are available as part of this\nrepository:\n\n- git-jcheck - a backwards compatible Git port of [jcheck](https://openjdk.org/projects/code-tools/jcheck/)\n- git-webrev - a backwards compatible Git port of [webrev](https://openjdk.org/projects/code-tools/webrev/)\n- git-defpath - a backwards compatible Git port of [defpath](https://openjdk.org/projects/code-tools/defpath/)\n- git-fork - fork a project on an external Git source code hosting provider to your personal space and optionally clone it\n- git-sync - sync the personal fork of the project with the current state of the upstream repository\n- git-pr - interact with pull requests for a project on an external Git source code hosting provider\n- git-info - show OpenJDK information about commits, e.g. issue links, authors, contributors, etc.\n- git-token - interact with a Git credential manager for handling personal access tokens\n- git-translate - translate between [Mercurial](https://mercurial-scm.org/)\nand [Git](https://git-scm.com/) hashes\n- git-skara - learn about and update the Skara CLI tools\n- git-trees - run a git command in a tree of repositories\n- git-publish - publishes a local branch to a remote repository\n- git-backport - backports a commit from another repository onto the current branch\n\nThere are also CLI tools available for importing OpenJDK\n[Mercurial](https://mercurial-scm.org/) repositories into\n[Git](https://git-scm.com/) repositories and vice versa:\n\n- git-openjdk-import\n- git-verify-import\n- hg-openjdk-import\n\nThe following server-side tools (so called \"bots\") for interacting with\nexternal Git source code hosting providers are available:\n\n- hgbridge - continuously convert Mercurial repositories to git\n- mlbridge - bridge messages between mailing lists and pull requests\n- notify - send email notifications when repositories are updated\n- pr - add OpenJDK workflow support for pull requests\n- submit - example pull request test runner\n- forward - forward commits to various repositories\n- mirror - mirror repositories\n- merge - merge commits between different repositories and/or branches\n- test - test runner\n\n## Building\n\n[JDK 21](http://jdk.java.net/21/) or later and [Gradle](https://gradle.org/)\n8.5 or later are required for building and will be automatically downloaded\nand installed by the custom gradlew script. To build the project on macOS or\nGNU/Linux x64, just run the following command from the source tree root:\n\n```bash\n$ sh gradlew\n```\n\nTo build the project on Windows x64, run the following command from the source\ntree root:\n\n```bat\n> gradlew\n```\n\nThe extracted jlinked image will end up in the `build` directory in the source\ntree root. _Note_ that the above commands will build the CLI tools, if you\nalso want to build the bot images run `sh gradlew images` on GNU/Linux or\n`gradlew images` on Windows.\n\n### Other operating systems and CPU architectures\n\nIf you want to build on an operating system other than GNU/Linux, macOS or\nWindows _or_ if you want to build on a CPU architecture other than x64, then\nensure that you have a JDK of suitable version or later installed locally and\nJAVA_HOME set to point to it. You can then run the following command from the\nsource tree root:\n\n```bash\n$ sh gradlew\n```\n\nThe extracted jlinked image will end up in the `build` directory in the source\ntree root.\n\n### Offline builds\n\nIf you don't want the build to automatically download any dependencies, then\nyou must ensure that you have installed the following software locally (see\nversion requirements above):\n\n- JDK\n- Gradle\n\nTo create a build then run the command:\n\n```bash\n$ gradle offline\n```\n\n_Please note_ that the above command does _not_ make use of `gradlew` to avoid\ndownloading Gradle.\n\nThe extracted jlinked image will end up in the `build` directory in the source\ntree root.\n\n### Cross-linking\n\nIt is also supported to cross-jlink jimages to GNU/Linux, macOS and/or Windows from\nany of the aforementioned operating systems. To build all applicable jimages\n(including the server-side tooling), run the following command from the\nsource tree root:\n\n```bash\nsh gradlew images\n```\n\n### Makefile wrapper\n\nSkara also has a very thin Makefile wrapper for contributors who prefer to build\nusing `make`. To build the jlinked image for the CLI tools using `make`, run:\n\n```bash\nmake\n```\n\n## Installing\n\nThere are multiple ways to install the Skara CLI tools. The easiest way is to\njust include `skara.gitconfig` in your global Git configuration file. You can also\ninstall the Skara tools on your `$PATH`.\n\n### Including skara.gitconfig\n\nTo install the Skara tools, include the `skara.gitconfig` Git configuration\nfile in your user-level Git configuration file. On macOS or\nGNU/Linux:\n\n```bash\n$ git config --global include.path \"$PWD/skara.gitconfig\"\n```\n\nOn Windows:\n\n```bat\n> git config --global include.path \"%CD%/skara.gitconfig\"\n```\n\nTo check that everything works as expected, run the command `git skara help`.\n\n### Adding to PATH\n\nThe Skara tools can also be added to `$PATH` on GNU/Linux and macOS and Git\nwill pick them up. You can either just extend `$PATH` with the `build/bin`\ndirectory or you can copy the tools to a location already on `$PATH`. To extend\n`$PATH` with the `build/bin` directory, run:\n\n```bash\n$ sh gradlew\n$ export PATH=\"$PWD/build/bin:$PATH\"\n```\n\nTo copy the tools to a location already on `$PATH`, run:\n\n```bash\n$ make\n$ make install prefix=/path/to/install/location\n```\n\nWhen running `make install` the default value of `prefix` is `$HOME/.local`.\n\nIf you want `git help <skara tool>` (or the equivalent `man git-<skara tool>`\nto work, you must also add the `build/bin/man` directory to `$MANPATH`.\nFor instance, run this from the Skara top directory to add this to your\n`.bashrc` file:\n\n```bash\necho \"export MANPATH=\\$MANPATH\":$PWD/build/bin/man >> ~/.bashrc\n```\n\n## Testing\n\n[JUnit](https://junit.org/junit5/) 5.8.2 or later is required to run the unit\ntests. To run the tests, execute following command from the source tree root:\n\n```bash\n$ sh gradlew test\n```\n\nIf you prefer to use the Makefile wrapper you can also run:\n\n```bash\n$ make test\n```\n\nThe tests expect [Git](https://git-scm.com/) version 2.19.3 or later and\n[Mercurial](https://mercurial-scm.org/) 4.7.2 or later to be installed on\nyour system.\n\nThis repository also contains a Dockerfile, `test.dockerfile`, that allows\nfor running the tests in a reproducible way with the proper dependencies\nconfigured. To run the tests in this way, run the following command from the\nsource tree root:\n\n```bash\n$ sh gradlew reproduce\n```\n\nIf you prefer to use the Makefile wrapper you can also run:\n\n```bash\n$ make reproduce\n```\n\n## Developing\n\nThere are no additional dependencies required for developing Skara if you can\nalready build and test it (see above for instructions). The command-line tools\nand libraries supports all of GNU/Linux, macOS and Windows and can therefore be\ndeveloped on any of those operating systems. The bots primarily support macOS\nand GNU/Linux and may require [Windows Subsystem for\nLinux](https://en.wikipedia.org/wiki/Windows_Subsystem_for_Linux) on Windows.\n\nPlease see the sections below for instructions on setting up a particular editor\nor IDE.\n\n### IntelliJ IDEA\n\nIf you choose to use [IntelliJ IDEA](https://www.jetbrains.com/idea/) as your\nIDE when working on Skara you can simply open the root folder and the project\nshould be automatically imported. You will need to configure a Platform SDK that\nis of the appropriate version (see above). Either set this up manually, or\n[build](#building) once from the terminal, which will download a suitable JDK.\nConfigure IntelliJ to use it at `File → Project Structure → Platform\nSettings → SDKs → + → Add JDK...` and browse to the downloaded JDK found\nin `<skara-folder>/.jdk/`. For example, on macOS, select the\n`<skara-folder>/.jdk/openjdk-21_osx-x64_bin/jdk-21.jdk/Contents/Home` folder.\n\n### Vim\n\nIf you choose to use [Vim](https://vim.org) as your editor when working on Skara then you\nprobably also want to utilize the Makefile wrapper. The Makefile wrapper enables\nto you to run `:make` and `:make tests` in Vim.\n\n## Wiki\n\nProject Skara's wiki is available at <https://wiki.openjdk.org/display/skara>.\n\n## Issues\n\nIssues are tracked in the [JDK Bug System](https://bugs.openjdk.org/)\nunder project Skara at <https://bugs.openjdk.org/projects/SKARA/>.\n\n## Contributing\n\nWe are more than happy to accept contributions to the Skara tooling, both via\npatches sent to the Skara\n[mailing list](https://mail.openjdk.org/mailman/listinfo/skara-dev) and in the\nform of pull requests on [GitHub](https://github.com/openjdk/skara/pulls/).\n\n## Members\n\nSee <http://openjdk.org/census#skara> for the current Skara\n[Reviewers](https://openjdk.org/bylaws#reviewer),\n[Committers](https://openjdk.org/bylaws#committer) and\n[Authors](https://openjdk.org/bylaws#author). See\n<https://openjdk.org/projects/> for how to become an author, committer\nor reviewer in an OpenJDK project.\n\n## Discuss\n\nDevelopment discussions take place on the project Skara mailing list\n`skara-dev@openjdk.org`, see\n<https://mail.openjdk.org/mailman/listinfo/skara-dev> for instructions\non how to subscribe of if you want to read the archives. You can also reach\nmany project Skara developers in the `#openjdk` IRC channel on\n[OFTC](https://www.oftc.net/), see <https://openjdk.org/irc/> for details.\n\n## License\n\nSee the file `LICENSE` for details.\n"
  },
  {
    "path": "Unzip.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.nio.file.Files;\nimport java.util.zip.ZipInputStream;\n\npublic class Unzip {\n    private static void unzip(Path zipFile, Path dest) throws IOException {\n        var stream = new ZipInputStream(Files.newInputStream(zipFile));\n        for (var entry = stream.getNextEntry(); entry != null; entry = stream.getNextEntry()) {\n            var path = dest.resolve(entry.getName());\n            if (entry.isDirectory()) {\n                Files.createDirectories(path);\n            } else {\n                if (Files.exists(path)) {\n                    Files.delete(path);\n                }\n                Files.copy(stream, path);\n            }\n        }\n    }\n\n    public static void main(String[] args) throws IOException {\n        unzip(Path.of(args[0]), Path.of(args[1]));\n    }\n}\n"
  },
  {
    "path": "args/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.args'\n    test {\n        requires 'org.junit.jupiter.api'\n        opens 'org.openjdk.skara.args' to 'org.junit.platform.commons'\n    }\n}\n\npublishing {\n    publications {\n        args(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.args {\n    exports org.openjdk.skara.args;\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/Argument.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\nimport java.util.NoSuchElementException;\nimport java.util.function.*;\n\npublic class Argument {\n    private final String value;\n\n    public Argument() {\n        this.value = null;\n    }\n\n    public Argument(String value) {\n        this.value = value;\n    }\n\n    public boolean isPresent() {\n        return value != null;\n    }\n\n    public <T> T via(Function<String, ? extends T> f) {\n        if (!isPresent()) {\n            throw new NoSuchElementException();\n        }\n\n        return f.apply(value);\n    }\n\n    public int asInt() {\n        return via(Integer::parseInt);\n    }\n\n    public double asDouble() {\n        return via(Double::parseDouble);\n    }\n\n    public float asFloat() {\n        return via(Float::parseFloat);\n    }\n\n    public boolean  asBoolean() {\n        return via(Boolean::parseBoolean);\n    }\n\n    public String asString() {\n        return value == null ? null : via(Function.identity());\n    }\n\n    public Argument or(int value) {\n        return isPresent() ? this : new Argument(Integer.toString(value));\n    }\n\n    public Argument or(double value) {\n        return isPresent() ? this : new Argument(Double.toString(value));\n    }\n\n    public Argument or(long value) {\n        return isPresent() ? this : new Argument(Long.toString(value));\n    }\n\n    public Argument or(boolean value) {\n        return isPresent() ? this : new Argument(Boolean.toString(value));\n    }\n\n    public Argument or(float value) {\n        return isPresent() ? this : new Argument(Float.toString(value));\n    }\n\n    public Argument or(String value) {\n        return isPresent() ? this : new Argument(value);\n    }\n\n    public Argument or(Argument other) {\n        return isPresent() ? this : other;\n    }\n\n    public Argument or(Supplier<String> supplier) {\n        return isPresent() ? this : new Argument(supplier.get());\n    }\n\n    public int orInt(int value) {\n        return orInt(() -> value);\n    }\n\n    public int orInt(Supplier<Integer> supplier) {\n        return isPresent() ? asInt() : supplier.get().intValue();\n    }\n\n    public double orDouble(double value) {\n        return orDouble(() -> value);\n    }\n\n    public double orDouble(Supplier<Double> supplier) {\n        return isPresent() ? asDouble() : supplier.get().doubleValue();\n    }\n\n    public float orFloat(float value) {\n        return orFloat(() -> value);\n    }\n\n    public float orFloat(Supplier<Float> supplier) {\n        return isPresent() ? asFloat() : supplier.get().floatValue();\n    }\n\n    public boolean orBoolean(boolean value) {\n        return orBoolean(() -> value);\n    }\n\n    public boolean orBoolean(Supplier<Boolean> supplier) {\n        return isPresent() ? asBoolean() : supplier.get().booleanValue();\n    }\n\n    public String orString(String value) {\n        return orString(() -> value);\n    }\n\n    public String orString(Supplier<String> supplier) {\n        return isPresent() ? asString() : supplier.get();\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/ArgumentParser.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\nimport java.io.PrintStream;\nimport java.util.*;\nimport java.util.function.Function;\n\npublic class ArgumentParser {\n    private final String programName;\n    private final List<Flag> flags;\n    private final List<Input> inputs;\n    private final Map<String, Flag> names = new HashMap<>();\n    private final boolean shouldShowHelp;\n\n    public ArgumentParser(String programName, List<Flag> flags) {\n        this(programName, flags, List.of());\n    }\n\n    public ArgumentParser(String programName, List<Flag> flags, List<Input> inputs) {\n        this.programName = programName;\n        this.flags = new ArrayList<>(flags);\n        this.inputs = inputs;\n\n        if (!flags.stream().anyMatch(f -> f.shortcut().equals(\"h\") && f.fullname().equals(\"help\"))) {\n            var help = Switch.shortcut(\"h\")\n                             .fullname(\"help\")\n                             .helptext(\"Show this help text\")\n                             .optional();\n            this.flags.add(help);\n            shouldShowHelp = true;\n        } else {\n            shouldShowHelp = false;\n        }\n\n        for (var flag : this.flags) {\n            if (!flag.fullname().equals(\"\")) {\n                names.put(flag.fullname(), flag);\n            }\n            if (!flag.shortcut().equals(\"\")) {\n                names.put(flag.shortcut(), flag);\n            }\n        }\n    }\n\n    private Flag lookupFlag(String name, boolean isShortcut) {\n        if (!names.containsKey(name)) {\n            System.err.print(\"Unexpected option: \");\n            System.err.print(isShortcut ? \"-\" : \"--\");\n            System.err.println(name);\n            showUsage();\n            System.exit(1);\n        }\n\n        return names.get(name);\n    }\n\n    private Flag lookupFullname(String name) {\n        return lookupFlag(name, false);\n    }\n\n    private Flag lookupShortcut(String name) {\n        return lookupFlag(name, true);\n    }\n\n    private static int longest(List<Flag> flags, Function<Flag, String> getName) {\n        return flags.stream()\n                    .map(getName)\n                    .filter(Objects::nonNull)\n                    .mapToInt(String::length)\n                    .reduce(0, Integer::max);\n    }\n\n    private static int longestShortcut(List<Flag> flags) {\n        return longest(flags, Flag::shortcut);\n    }\n\n    private static int longestFullname(List<Flag> flags) {\n        return longest(flags, f -> f.fullname() + \" \" + f.description());\n    }\n\n    public void showUsage() {\n        showUsage(System.out);\n    }\n\n    public static void showFlags(PrintStream ps, List<Flag> flags, String prefix) {\n        var shortcutPad = longestShortcut(flags) + 1 + 2; // +1 for '-' and +2 for ', '\n        var fullnamePad = longestFullname(flags) + 2 + 2; // +2 for '--' and +2 for '  '\n\n        for (var flag : flags) {\n            ps.print(prefix);\n            var fmt = \"%-\" + shortcutPad + \"s\";\n            var s = flag.shortcut().equals(\"\") ? \" \" : \"-\" + flag.shortcut() + \", \";\n            ps.print(String.format(fmt, s));\n\n            fmt = \"%-\" + fullnamePad + \"s\";\n            var desc = flag.description().equals(\"\") ? \"\" : \" \" + flag.description();\n            s = flag.fullname().equals(\"\") ? \" \" : \"--\" + flag.fullname() + desc + \"  \";\n            ps.print(String.format(fmt, s));\n\n            if (!flag.helptext().equals(\"\")) {\n                ps.print(flag.helptext());\n            }\n\n            ps.println(\"\");\n        }\n    }\n\n    public void showUsage(PrintStream ps) {\n        ps.print(\"usage: \");\n        ps.print(programName);\n        ps.print(\" [options]\");\n        for (var flag : flags) {\n            if (flag.isRequired()) {\n                ps.print(\" \");\n                if (!flag.fullname().equals(\"\")) {\n                    ps.print(\"--\");\n                    ps.print(flag.fullname());\n                    if (!flag.description().equals(\"\")) {\n                        ps.print(\"=\");\n                        ps.print(flag.description());\n                    }\n                } else {\n                    ps.print(\"-\" + flag.shortcut());\n                    if (!flag.description().equals(\"\")) {\n                        ps.print(\" \");\n                        ps.print(flag.description());\n                    }\n                }\n            }\n        }\n        for (var input : inputs) {\n            ps.print(\" \");\n            ps.print(input.toString());\n        }\n        ps.println(\"\");\n\n        showFlags(ps, flags, \"\\t\");\n    }\n\n    public Arguments parse(String[] args) {\n        var seen = new HashSet<Flag>();\n        var values = new ArrayList<FlagValue>();\n        var positional = new ArrayList<String>();\n\n        var i = 0;\n        while (i < args.length) {\n            var arg = args[i];\n\n            if (arg.startsWith(\"--\")) {\n                if (arg.contains(\"=\")) {\n                    var parts = arg.split(\"=\");\n                    var name = parts[0].substring(2); // remove leading '--'\n                    var value = parts.length == 2 ? parts[1] : null;\n                    var flag = lookupFullname(name);\n                    values.add(new FlagValue(flag, value));\n                    seen.add(flag);\n                } else {\n                    var name = arg.substring(2);\n                    var flag = lookupFullname(name);\n                    if (flag.isSwitch()) {\n                        values.add(new FlagValue(flag, \"true\"));\n                    } else {\n                        if (i < (args.length - 1)) {\n                            var value = args[i + 1];\n                            values.add(new FlagValue(flag, value));\n                            i++;\n                        } else {\n                            values.add(new FlagValue(flag));\n                        }\n                    }\n                    seen.add(flag);\n                }\n            } else if (arg.startsWith(\"-\") && !arg.equals(\"-\")) {\n                var name = arg.substring(1);\n                var flag = lookupShortcut(name);\n                if (flag.isSwitch()) {\n                    values.add(new FlagValue(flag, \"true\"));\n                } else {\n                    if (i < (args.length - 1)) {\n                        var value = args[i + 1];\n                        values.add(new FlagValue(flag, value));\n                        i++;\n                    } else {\n                        values.add(new FlagValue(flag));\n                    }\n                }\n                seen.add(flag);\n            } else {\n                int argPos = positional.size();\n                if (argPos >= inputs.size()) {\n                    // must check if permitted\n                    if (inputs.size() == 0) {\n                        System.err.println(\"error: unexpected input: \" + arg);\n                        showUsage();\n                        System.exit(1);\n                    }\n                    var last = inputs.getLast();\n                    if ((last.getPosition() + last.getOccurrences()) <= argPos && !last.isTrailing()) {\n                        // this input is not permitted\n                        System.err.println(\"error: unexpected input: \" + arg);\n                        showUsage();\n                        System.exit(1);\n                    }\n                }\n\n                positional.add(arg);\n            }\n            i++;\n        }\n\n        var arguments = new Arguments(values, positional);\n        if (arguments.contains(\"help\") && shouldShowHelp) {\n            showUsage();\n            System.exit(0);\n        }\n\n        var errors = new ArrayList<String>();\n        for (var flag : flags) {\n            if (flag.isRequired() && !seen.contains(flag)) {\n                errors.add(\"error: missing required flag: \" + flag.toString());\n            }\n        }\n        for (var input : inputs) {\n            if (input.isRequired() && !(positional.size() > input.getPosition())) {\n                errors.add(\"error: missing required input: \" + input.toString());\n            }\n        }\n\n        // If --version is specified then don't care about required flags or inputs\n        var showVersion = arguments.contains(\"version\");\n        if (!errors.isEmpty() && !showVersion) {\n            for (var error : errors) {\n                System.err.println(error);\n            }\n            showUsage();\n            System.exit(1);\n        }\n\n        return arguments;\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/Arguments.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\nimport java.util.*;\nimport java.util.stream.Collectors;\n\npublic class Arguments {\n    private final List<String> positionals;\n    private final Map<String, FlagValue> names = new HashMap<>();\n\n    public Arguments(List<FlagValue> flags, List<String> positionals) {\n        this.positionals = positionals;\n\n        for (var flag : flags) {\n            if (flag.fullname() != null) {\n                names.put(flag.fullname(), flag);\n            }\n            if (flag.shortcut() != null) {\n                names.put(flag.shortcut(), flag);\n            }\n        }\n    }\n\n    public List<Argument> inputs() {\n        return positionals.stream()\n                          .map(Argument::new)\n                          .collect(Collectors.toList());\n    }\n\n    public Argument at(int pos) {\n        if (pos < positionals.size()) {\n            return new Argument(positionals.get(pos));\n        } else {\n            return new Argument();\n        }\n    }\n\n    public Argument get(String name) {\n        if (names.containsKey(name)) {\n            return new Argument(names.get(name).value());\n        }\n\n        return new Argument();\n    }\n\n    public boolean contains(String name) {\n        return names.containsKey(name);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/Command.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class Command implements Main {\n    private final String name;\n    private final String helpText;\n    private final Main main;\n\n    Command(String name, String helpText, Main main) {\n        this.name = name;\n        this.helpText = helpText;\n        this.main = main;\n    }\n\n    public String name() {\n        return name;\n    }\n\n    public String helpText() {\n        return helpText;\n    }\n\n    public Main main() {\n        return main;\n    }\n\n    public static CommandHelpText name(String name) {\n        return new CommandHelpText<>(Command::new, name);\n    }\n\n    @Override\n    public void main(String[] args) throws Exception {\n        main.main(args);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/CommandCtor.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic interface CommandCtor<T extends Command> {\n    T construct(String name, String helpText, Main main);\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/CommandHelpText.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class CommandHelpText<T extends Command> {\n    private final CommandCtor<T> ctor;\n    private final String name;\n\n    CommandHelpText(CommandCtor<T> ctor, String name) {\n        this.ctor = ctor;\n        this.name = name;\n    }\n\n    public CommandMain<T> helptext(String helpText) {\n        return new CommandMain<>(ctor, name, helpText);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/CommandMain.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class CommandMain<T extends Command> {\n    private final CommandCtor<T> ctor;\n    private final String name;\n    private final String helpText;\n\n    CommandMain(CommandCtor<T> ctor, String name, String helpText) {\n        this.ctor = ctor;\n        this.name = name;\n        this.helpText = helpText;\n    }\n\n    public T main(Main main) {\n        return ctor.construct(name, helpText, main);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/Default.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class Default extends Command {\n    Default(String name, String helpText, Main main) {\n        super(name, helpText, main);\n    }\n\n    public static CommandHelpText<Default> name(String name) {\n        return new CommandHelpText<>(Default::new, name);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/Executable.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\n@FunctionalInterface\npublic interface Executable {\n    void execute() throws Exception;\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/Flag.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\nimport java.util.Objects;\n\npublic class Flag {\n    private boolean isSwitch;\n    private final String shortcut;\n    private final String fullname;\n    private final String description;\n    private final String helptext;\n    private final boolean isRequired;\n\n    Flag(boolean isSwitch, String shortcut, String fullname, String description, String helptext, boolean isRequired) {\n        this.isSwitch = isSwitch;\n        this.shortcut = shortcut;\n        this.fullname = fullname;\n        this.description = description;\n        this.helptext = helptext;\n        this.isRequired = isRequired;\n    }\n\n    boolean isSwitch() {\n        return isSwitch;\n    }\n\n    public String fullname() {\n        return fullname;\n    }\n\n    public String shortcut() {\n        return shortcut;\n    }\n\n    public String description() {\n        return description;\n    }\n\n    public String helptext() {\n        return helptext;\n    }\n\n    boolean isRequired() {\n        return isRequired;\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof Flag other)) {\n            return false;\n        }\n\n        return Objects.equals(isSwitch, other.isSwitch) &&\n               Objects.equals(shortcut, other.shortcut) &&\n               Objects.equals(fullname, other.fullname) &&\n               Objects.equals(helptext, other.helptext) &&\n               Objects.equals(isRequired, other.isRequired);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(isSwitch,\n                            shortcut,\n                            fullname,\n                            helptext,\n                            isRequired);\n    }\n\n    @Override\n    public String toString() {\n        if (shortcut.equals(\"\")) {\n            return \"--\" + fullname;\n        }\n\n        if (fullname.equals(\"\")) {\n            return \"-\" + shortcut;\n        }\n\n        return \"-\" + shortcut + \", --\" + fullname;\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/FlagValue.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\nclass FlagValue {\n    private final Flag flag;\n    private final String value;\n\n    FlagValue(Flag flag) {\n        this.flag = flag;\n        this.value = null;\n    }\n\n    FlagValue(Flag flag, String value) {\n        this.flag = flag;\n        this.value = value;\n    }\n\n    boolean isSwitch() {\n        return flag.isSwitch();\n    }\n\n    String fullname() {\n        return flag.fullname();\n    }\n\n    String shortcut() {\n        return flag.shortcut();\n    }\n\n    String helptext() {\n        return flag.helptext();\n    }\n\n    boolean isRequired() {\n        return flag.isRequired();\n    }\n\n    String value() {\n        return value;\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/Input.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class Input {\n    private final int position;\n    private final String description;\n    private final int occurrences;\n    private final boolean required;\n\n    Input(int position, String description, int occurrences, boolean required) {\n        this.position = position;\n        this.description = description;\n        this.occurrences = occurrences;\n        this.required = required;\n    }\n\n    public static InputDescriber position(int p) {\n        return new InputDescriber(p);\n    }\n\n    public int getPosition() {\n        return position;\n    }\n\n    public String getDescription() {\n        return description;\n    }\n\n    public int getOccurrences() {\n        return occurrences;\n    }\n\n    public boolean isTrailing() {\n        return occurrences == -1;\n    }\n\n    public boolean isRequired() {\n        return required;\n    }\n\n    @Override\n    public String toString() {\n        var builder = new StringBuilder();\n        var n = isTrailing() ? 1 : occurrences;\n        for (var i = 0; i < n; i++) {\n            if (!isRequired()) {\n                builder.append(\"[\");\n            }\n            builder.append(\"<\");\n            builder.append(description);\n            builder.append(\">\");\n            if (!isRequired()) {\n                builder.append(\"]\");\n            }\n            if (i != (n - 1)) {\n                builder.append(\" \");\n            }\n\n            if (isTrailing()) {\n                builder.append(\"...\");\n            }\n        }\n\n        return builder.toString();\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/InputDescriber.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class InputDescriber {\n    private final int position;\n\n    InputDescriber(int position) {\n        this.position = position;\n    }\n\n    public InputQuantifier describe(String description) {\n        return new InputQuantifier(position, description);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/InputQualifier.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class InputQualifier {\n    private final int position;\n    private final String description;\n    private final int occurrences;\n\n    InputQualifier(int position, String description, int occurrences) {\n        this.position = position;\n        this.description = description;\n        this.occurrences = occurrences;\n    }\n\n    public Input optional() {\n        return new Input(position, description, occurrences, false);\n    }\n\n    public Input required() {\n        return new Input(position, description, occurrences, true);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/InputQuantifier.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class InputQuantifier {\n    private final int position;\n    private final String description;\n\n    InputQuantifier(int position, String description) {\n        this.position = position;\n        this.description = description;\n    }\n\n    public InputQualifier singular() {\n        return new InputQualifier(position, description, 1);\n    }\n\n    public InputQualifier multiple(int n) {\n        if (n < 1) {\n            throw new IllegalArgumentException(n + \" must be larger than 1\");\n        }\n        return new InputQualifier(position, description, n);\n    }\n\n    public InputQualifier trailing() {\n        return new InputQualifier(position, description, -1);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/Main.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\n@FunctionalInterface\npublic interface Main {\n    void main(String[] args) throws Exception;\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/MultiCommandParser.java",
    "content": "/*\n * Copyright (c) 2019, 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\nimport java.io.PrintStream;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\n\npublic class MultiCommandParser {\n    private final String programName;\n    private final String defaultCommand;\n    private final Map<String, Command> subCommands;\n    private final boolean defaultCommandWarningEnabled;\n\n    public MultiCommandParser(String programName, List<Command> commands, boolean defaultCommandWarningEnabled) {\n        var defaults = commands.stream().filter(Default.class::isInstance).collect(Collectors.toList());\n        if (defaults.size() != 1) {\n            throw new IllegalArgumentException(\"Expecting exactly one default command\");\n        }\n        this.defaultCommand = defaults.get(0).name();\n\n        this.programName = programName;\n        this.subCommands = commands.stream()\n                                   .collect(Collectors.toMap(\n                                           Command::name,\n                                           Function.identity()));\n        this.defaultCommandWarningEnabled = defaultCommandWarningEnabled;\n        if (!commands.stream().anyMatch(c -> c.name().equals(\"help\"))) {\n            this.subCommands.put(\"help\", helpCommand());\n        }\n    }\n\n    private Command helpCommand() {\n        return new Command(\"help\", \"print a help message\", args -> showUsage());\n    }\n\n    public Executable parse(String[] args) {\n        if (args.length > 0) {\n            var p = subCommands.get(args[0]);\n            if (p != null) {\n                var forwardedArgs = Arrays.copyOfRange(args, 1, args.length);\n                return () -> p.main(forwardedArgs);\n            }\n            if (defaultCommandWarningEnabled) {\n                System.err.println(\"warning: unknown sub-command: \" + args[0]);\n                System.err.println(\"the default sub-command '\" + defaultCommand +\n                        \"' will be executed with the arguments \" + Arrays.toString(args) + \"\\n\");\n            }\n        }\n        return () -> subCommands.get(defaultCommand).main(args);\n    }\n\n    private void showUsage() {\n        showUsage(System.out);\n    }\n\n    private void showUsage(PrintStream ps) {\n        ps.print(\"usage: \");\n        ps.print(programName);\n        ps.print(subCommands.keySet().stream().collect(Collectors.joining(\"|\", \" <\", \">\")));\n        ps.println(\" <input>\");\n\n        int spacing = subCommands.keySet().stream().mapToInt(String::length).max().orElse(0);\n        spacing += 8; // some room\n\n        for (var subCommand : subCommands.values()) {\n            ps.println(String.format(\"  %-\" + spacing + \"s%s\", subCommand.name(), subCommand.helpText()));\n        }\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/Option.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class Option {\n    public static OptionFullname shortcut(String s) {\n        return new OptionFullname(s);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/OptionDescribe.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class OptionDescribe {\n    private final String shortcut;\n    private final String fullname;\n\n    OptionDescribe(String shortcut, String fullname) {\n        this.shortcut = shortcut;\n        this.fullname = fullname;\n    }\n\n    public OptionHelptext describe(String desc) {\n        return new OptionHelptext(shortcut, fullname, desc);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/OptionFullname.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class OptionFullname {\n    private final String shortcut;\n\n    OptionFullname(String shortcut) {\n        this.shortcut = shortcut;\n    }\n\n    public OptionDescribe fullname(String name) {\n        return new OptionDescribe(shortcut, name);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/OptionHelptext.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class OptionHelptext {\n    private final String shortcut;\n    private final String fullname;\n    private final String description;\n\n    OptionHelptext(String shortcut, String fullname, String description) {\n        this.shortcut = shortcut;\n        this.fullname = fullname;\n        this.description = description;\n    }\n\n    public OptionQualifier helptext(String help) {\n        return new OptionQualifier(shortcut, fullname, description, help);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/OptionQualifier.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class OptionQualifier {\n    private final String shortcut;\n    private final String fullname;\n    private final String description;\n    private final String helptext;\n\n    OptionQualifier(String shortcut, String fullname, String description, String helptext) {\n        this.shortcut = shortcut;\n        this.fullname = fullname;\n        this.description = description;\n        this.helptext = helptext;\n    }\n\n    public Flag required() {\n        return new Flag(false, shortcut, fullname, description, helptext, true);\n    }\n\n    public Flag optional() {\n        return new Flag(false, shortcut, fullname, description, helptext, false);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/Switch.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class Switch {\n    public static SwitchFullname shortcut(String s) {\n        return new SwitchFullname(s);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/SwitchFullname.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class SwitchFullname {\n    private final String shortcut;\n\n    SwitchFullname(String shortcut) {\n        this.shortcut = shortcut;\n    }\n\n    public SwitchHelptext fullname(String name) {\n        return new SwitchHelptext(shortcut, name);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/SwitchHelptext.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class SwitchHelptext {\n    private final String shortcut;\n    private final String fullname;\n\n    SwitchHelptext(String shortcut, String fullname) {\n        this.shortcut = shortcut;\n        this.fullname = fullname;\n    }\n\n    public SwitchQualifier helptext(String help) {\n        return new SwitchQualifier(shortcut, fullname, help);\n    }\n}\n"
  },
  {
    "path": "args/src/main/java/org/openjdk/skara/args/SwitchQualifier.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\npublic class SwitchQualifier {\n    private final String shortcut;\n    private final String fullname;\n    private final String helptext;\n\n    SwitchQualifier(String shortcut, String fullname, String helptext) {\n        this.shortcut = shortcut;\n        this.fullname = fullname;\n        this.helptext = helptext;\n    }\n\n    public Flag required() {\n        return new Flag(true, shortcut, fullname, \"\", helptext, true);\n    }\n\n    public Flag optional() {\n        return new Flag(true, shortcut, fullname, \"\", helptext, false);\n    }\n}\n"
  },
  {
    "path": "args/src/test/java/org/openjdk/skara/args/InputTests.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\npublic class InputTests {\n    @Test\n    void trailingToString() {\n        var i = Input.position(0)\n                     .describe(\"ARG\")\n                     .trailing()\n                     .required();\n        assertEquals(\"<ARG>...\", i.toString());\n    }\n\n    @Test\n    void singleToString() {\n        var i = Input.position(0)\n                     .describe(\"ARG\")\n                     .singular()\n                     .required();\n        assertEquals(\"<ARG>\", i.toString());\n    }\n\n    @Test\n    void multipleToString() {\n        var i = Input.position(0)\n                     .describe(\"ARG\")\n                     .multiple(2)\n                     .required();\n        assertEquals(\"<ARG> <ARG>\", i.toString());\n    }\n\n    @Test\n    void optionalToString() {\n        var i = Input.position(0)\n                     .describe(\"ARG\")\n                     .singular()\n                     .optional();\n        assertEquals(\"[<ARG>]\", i.toString());\n    }\n}\n"
  },
  {
    "path": "args/src/test/java/org/openjdk/skara/args/SwitchTests.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.args;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\npublic class SwitchTests {\n    @Test\n    void testFlagDescIsSwitch() {\n        var f = Switch.shortcut(\"s\")\n                      .fullname(\"switch\")\n                      .helptext(\"This is a switch\")\n                      .optional();\n        assertTrue(f.isSwitch());\n    }\n}\n"
  },
  {
    "path": "bot/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bot'\n    test {\n        requires 'org.junit.jupiter.api'\n        opens 'org.openjdk.skara.bot' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':ci')\n    implementation project(':host')\n    implementation project(':network')\n    implementation project(':issuetracker')\n    implementation project(':forge')\n    implementation project(':vcs')\n    implementation project(':json')\n    implementation project(':census')\n    implementation project(':metrics')\n    implementation project(':version')\n}\n\npublishing {\n    publications {\n        bot(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "bot/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.bot {\n    requires transitive org.openjdk.skara.ci;\n    requires transitive org.openjdk.skara.host;\n    requires transitive org.openjdk.skara.issuetracker;\n    requires transitive org.openjdk.skara.forge;\n    requires transitive org.openjdk.skara.json;\n    requires transitive org.openjdk.skara.census;\n    requires transitive org.openjdk.skara.metrics;\n    requires org.openjdk.skara.network;\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.version;\n    requires java.logging;\n    requires java.management;\n    requires jdk.management;\n    requires jdk.httpserver;\n    requires jdk.jfr;\n\n    exports org.openjdk.skara.bot;\n\n    uses org.openjdk.skara.bot.BotFactory;\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/Bot.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport org.openjdk.skara.json.JSONValue;\n\nimport java.util.List;\n\npublic interface Bot {\n    List<WorkItem> getPeriodicItems();\n    default List<WorkItem> processWebHook(JSONValue body) {\n        return List.of();\n    };\n    String name();\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/BotConfiguration.java",
    "content": "/*\n * Copyright (c) 2018, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport org.openjdk.skara.ci.ContinuousIntegration;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.issuetracker.IssueProject;\nimport org.openjdk.skara.issuetracker.IssueTracker;\nimport org.openjdk.skara.json.JSONObject;\n\nimport java.nio.file.Path;\n\npublic interface BotConfiguration {\n    /**\n     * Folder that WorkItems may use to store permanent data.\n     * @return\n     */\n    Path storageFolder();\n\n    /**\n     * Configuration-specific name mapped to a HostedRepository.\n     * @param name\n     * @return\n     */\n    HostedRepository repository(String name);\n\n    /**\n     * Configuration-specific name mapped to an IssueProject.\n     * @param name\n     * @return\n     */\n    IssueProject issueProject(String name);\n\n    /**\n     * Configuration-specific name mapped to an IssueTracker.\n     * @param name\n     * @return\n     */\n    IssueTracker issueTracker(String name);\n\n    /**\n     * Configuration-specific name mapped to a ContinuousIntegration.\n     * @param name\n     * @return\n     */\n    ContinuousIntegration continuousIntegration(String name);\n\n    /**\n     * Retrieves the ref name that optionally follows the configuration-specific repository name.\n     * If not configured, returns the name of the VCS default branch.\n     * @param name\n     * @return\n     */\n    String repositoryRef(String name);\n\n    /**\n     * Extracts a reasonable short repository name from a full repository specification, e.g. host/org/repo:ref -> repo\n     * @param name\n     * @return\n     */\n    String repositoryName(String name);\n\n    /**\n     * Additional bot-specific configuration.\n     * @return\n     */\n    JSONObject specific();\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/BotFactory.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport java.util.*;\nimport java.util.stream.*;\n\npublic interface BotFactory {\n\n    /**\n     * A user-friendly name for the given bot, used for configuration section naming. Should be lower case.\n     * @return\n     */\n    String name();\n\n    /**\n     * Instantiate instances of this bot with the given configuration.\n     * @param configuration\n     * @return\n     */\n    List<Bot> create(BotConfiguration configuration);\n\n    static List<BotFactory> getBotFactories() {\n        return StreamSupport.stream(ServiceLoader.load(BotFactory.class).spliterator(), false)\n                            .collect(Collectors.toList());\n    }\n\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/BotRunner.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport java.util.concurrent.atomic.AtomicInteger;\nimport org.openjdk.skara.json.JSONValue;\nimport org.openjdk.skara.metrics.*;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.net.*;\nimport java.time.*;\nimport java.util.*;\nimport java.util.concurrent.*;\nimport java.util.logging.*;\nimport java.lang.management.ManagementFactory;\nimport com.sun.management.ThreadMXBean;\n\nimport com.sun.net.httpserver.*;\nimport org.openjdk.skara.network.RestRequest;\nimport org.openjdk.skara.network.UncheckedRestException;\n\nclass BotRunnerError extends RuntimeException {\n    BotRunnerError(String msg) {\n        super(msg);\n    }\n\n    BotRunnerError(String msg, Throwable suppressed) {\n        super(msg);\n        addSuppressed(suppressed);\n    }\n}\n\npublic class BotRunner {\n    enum TaskPhases {\n        BEGIN,\n        END\n    }\n\n    private final AtomicInteger workIdCounter = new AtomicInteger();\n\n    /**\n     * A wrapper for a WorkItem while it's tracked as pending. Used to track\n     * when a particular WorkItem entered the pending state so that metrics\n     * and log messages can use this information.\n     */\n    private static class PendingWorkItem {\n        private final WorkItem item;\n        private final Instant createTime;\n\n        public PendingWorkItem(WorkItem item) {\n            this(item, null);\n        }\n\n        public PendingWorkItem(WorkItem item, Instant originalCreateTime) {\n            this.item = item;\n            if (originalCreateTime != null) {\n                this.createTime = originalCreateTime;\n            } else {\n                this.createTime = Instant.now();\n            }\n        }\n    }\n\n    private class RunnableWorkItem implements Runnable {\n        private static final Counter.WithThreeLabels EXCEPTIONS_COUNTER =\n            Counter.name(\"skara_runner_exceptions\").labels(\"bot\", \"work_item\", \"exception\").register();\n        /**\n         * Gauge that tracks the time WorkItems have been pending before\n         * being submitted.\n         */\n        private static final Gauge.WithTwoLabels PENDING_TIME_GAUGE =\n            Gauge.name(\"skara_runner_pending_time\").labels(\"bot\", \"work_item\").register();\n        /**\n         * Gauge that tracks the time WorkItems have been submitted before\n         * starting to run.\n         */\n        private static final Gauge.WithTwoLabels SUBMITTED_TIME_GAUGE =\n                Gauge.name(\"skara_runner_submitted_time\").labels(\"bot\", \"work_item\").register();\n        private static final Counter.WithTwoLabels TIME_COUNTER =\n                Counter.name(\"skara_runner_run_time_total\").labels(\"bot\", \"work_item\").register();\n        private static final Counter.WithTwoLabels ITEM_FINISHED_COUNTER =\n                Counter.name(\"skara_runner_finished_counter\").labels(\"bot\", \"work_item\").register();\n        private static final Counter.WithTwoLabels CPU_TIME_COUNTER =\n                Counter.name(\"skara_runner_cpu_time_total\").labels(\"bot\", \"work_item\").register();\n        private static final Counter.WithTwoLabels ALLOCATED_BYTES_COUNTER =\n                Counter.name(\"skara_runner_allocated_bytes_total\").labels(\"bot\", \"work_item\").register();\n\n        private final WorkItem item;\n        private final int workId = workIdCounter.incrementAndGet();\n        private final Instant createTime = Instant.now();\n        // This gets updated by the watchdog when a timeout occurs to avoid\n        // repeating the timeout log messages too often.\n        private Instant timeoutWarningTime = createTime;\n\n        RunnableWorkItem(WorkItem wrappedItem) {\n            item = wrappedItem;\n        }\n\n        public WorkItem get() {\n            return item;\n        }\n\n        private static Optional<ThreadMXBean> getThreadMXBean() {\n            var bean = ManagementFactory.getThreadMXBean();\n            return bean instanceof ThreadMXBean b ?\n                Optional.of(b) : Optional.empty();\n        }\n\n        private static void enableThreadCpuTime() {\n            var bean = getThreadMXBean();\n            if (bean.get().isCurrentThreadCpuTimeSupported() && !bean.get().isThreadCpuTimeEnabled()) {\n                bean.get().setThreadCpuTimeEnabled(true);\n            }\n        }\n\n        private static long getCurrentThreadCpuTime() {\n            var bean = getThreadMXBean();\n            if (bean.isEmpty()) {\n                return -1L;\n            }\n            return bean.get().isCurrentThreadCpuTimeSupported()?\n                bean.get().getCurrentThreadCpuTime() :\n                -1L;\n        }\n\n        private static long getCurrentThreadAllocatedBytes() {\n            var bean = getThreadMXBean();\n            if (bean.isEmpty()) {\n                return -1L;\n            }\n\n            if (!bean.get().isThreadAllocatedMemorySupported()) {\n                return -1L;\n            }\n\n            if (!bean.get().isThreadAllocatedMemoryEnabled()) {\n                bean.get().setThreadAllocatedMemoryEnabled(true);\n            }\n\n            return bean.get().getCurrentThreadAllocatedBytes();\n        }\n\n        @Override\n        public void run() {\n            enableThreadCpuTime();\n            long startCpuTimeNs = getCurrentThreadCpuTime();\n            long startAllocatedBytes = getCurrentThreadAllocatedBytes();\n            var start = Instant.now();\n\n            try {\n                runMeasured();\n            } finally {\n                ITEM_FINISHED_COUNTER.labels(item.botName(), item.workItemName()).inc();\n                long stopCpuTimeNs = getCurrentThreadCpuTime();\n                long stopAllocatedBytes = getCurrentThreadAllocatedBytes();\n\n                var cpuTimeNs = (startCpuTimeNs == -1L && stopCpuTimeNs == -1L)?\n                    -1L : stopCpuTimeNs - startCpuTimeNs;\n                var allocatedBytes = (startAllocatedBytes == -1L && stopAllocatedBytes == -1L)?\n                    -1L : stopAllocatedBytes - startAllocatedBytes;\n\n                if (cpuTimeNs != -1L) {\n                    double cpuTimeSeconds = cpuTimeNs / 1_000_000_000.0;\n                    CPU_TIME_COUNTER.labels(item.botName(), item.workItemName()).inc(cpuTimeSeconds);\n                }\n                if (allocatedBytes != -1L) {\n                    ALLOCATED_BYTES_COUNTER.labels(item.botName(), item.workItemName()).inc(allocatedBytes);\n                }\n                TIME_COUNTER.labels(item.botName(), item.workItemName()).inc(\n                        Duration.between(start, Instant.now()).toMillis() / 1_000.0);\n            }\n        }\n\n        private void runMeasured() {\n            Path scratchPath;\n\n            synchronized (executor) {\n                if (scratchPaths.isEmpty()) {\n                    log.warning(\"No scratch paths available - postponing \" + item);\n                    addPending(new PendingWorkItem(item), null);\n                    return;\n                }\n                scratchPath = scratchPaths.removeFirst();\n            }\n\n            Collection<WorkItem> followUpItems = null;\n            var start = Instant.now();\n            try (var __ = new LogContext(Map.of(\"work_item\", item.toString(),\n                    \"work_id\", String.valueOf(workId)))) {\n                var submittedDuration = Duration.between(createTime, start);\n                SUBMITTED_TIME_GAUGE.labels(item.botName(), item.workItemName()).set(submittedDuration.toMillis() / 1_000.0);\n                log.log(Level.FINE, \"Executing item \" + item + \" on repository \" + scratchPath\n                        + \" after being submitted for \" + submittedDuration,\n                        new Object[]{TaskPhases.BEGIN, submittedDuration});\n                try {\n                    followUpItems = item.run(scratchPath);\n                } catch (UncheckedRestException e) {\n                    EXCEPTIONS_COUNTER.labels(item.botName(), item.workItemName(), e.getClass().getName()).inc();\n                    // Log as WARNING to avoid triggering alarms. Failed REST calls are tracked\n                    // using metrics.\n                    log.log(Level.WARNING, \"RestException during item execution (\" + item + \"): \"\n                            + e.getMessage(), e);\n                    item.handleRuntimeException(e);\n                } catch (RuntimeException e) {\n                    EXCEPTIONS_COUNTER.labels(item.botName(), item.workItemName(), e.getClass().getName()).inc();\n                    if (e.getCause() instanceof UncheckedRestException) {\n                        // Log as WARNING to avoid triggering alarms. Failed REST calls are tracked\n                        // using metrics.\n                        log.log(Level.WARNING, \"RestException during item execution (\" + item + \")\"\n                                + e.getCause().getMessage(), e.getCause());\n                    } else {\n                        log.log(Level.SEVERE, \"Exception during item execution (\" + item + \"): \" + e.getMessage(), e);\n                    }\n                    item.handleRuntimeException(e);\n                } catch (Error e) {\n                    EXCEPTIONS_COUNTER.labels(item.botName(), item.workItemName(), e.getClass().getName()).inc();\n                    log.log(Level.SEVERE, \"Error thrown during item execution: (\" + item + \"): \" + e.getMessage(), e);\n                    throw e;\n                } finally {\n                    var duration = Duration.between(start, Instant.now());\n                    log.log(Level.FINE, \"Item \" + item + \" is now done after \" + duration,\n                            new Object[]{TaskPhases.END, duration});\n                    synchronized (executor) {\n                        scratchPaths.addLast(scratchPath);\n                        done(item);\n                    }\n                }\n                if (followUpItems != null) {\n                    followUpItems.forEach(BotRunner.this::submitOrSchedule);\n                }\n\n                synchronized (executor) {\n                    // Some of the pending items may now be eligible for execution\n                    var candidateItems = pending.entrySet().stream()\n                            .filter(e -> e.getValue().isEmpty() || !active.containsKey(e.getValue().get()))\n                            .map(Map.Entry::getKey)\n                            .toList();\n\n                    // Try the candidates against the current active set\n                    for (var candidate : candidateItems) {\n                        boolean maySubmit = true;\n                        for (var activeItem : active.keySet()) {\n                            if (!activeItem.concurrentWith(candidate.item)) {\n                                // Still can't run this candidate, leave it pending\n                                log.finer(\"Cannot submit candidate \" + candidate + \" - not concurrent with \" + activeItem);\n                                maySubmit = false;\n                                break;\n                            }\n                        }\n\n                        if (maySubmit) {\n                            removePending(candidate);\n                            submit(candidate.item);\n                            var timeSinceCreation = Duration.between(candidate.createTime, Instant.now());\n                            PENDING_TIME_GAUGE.labels(candidate.item.botName(), candidate.item.workItemName())\n                                    .set(timeSinceCreation.toMillis() / 1_000.0);\n                            log.log(Level.FINE, \"Submitting item \" + candidate.item\n                                    + \" after being pending for \" + timeSinceCreation, timeSinceCreation);\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    // Mapping of pending items to the active item preventing them from running\n    private final Map<PendingWorkItem, Optional<WorkItem>> pending;\n    // Mapping of active WorkItem to their RunnableWorkItem\n    private final Map<WorkItem, RunnableWorkItem> active;\n    private final Deque<Path> scratchPaths;\n\n    private static final Counter.WithTwoLabels SCHEDULED_COUNTER =\n            Counter.name(\"skara_runner_scheduled_counter\").labels(\"bot\", \"work_item\").register();\n    private static final Counter.WithTwoLabels PENDING_COUNTER =\n            Counter.name(\"skara_runner_pending_counter\").labels(\"bot\", \"work_item\").register();\n    private static final Counter.WithTwoLabels SUBMITTED_COUNTER =\n            Counter.name(\"skara_runner_submitted_counter\").labels(\"bot\", \"work_item\").register();\n    private static final Counter.WithTwoLabels DISCARDED_COUNTER =\n            Counter.name(\"skara_runner_discarded_counter\").labels(\"bot\", \"work_item\").register();\n    /**\n     * Gauge that tracks the number of active WorkItems for each kind\n     */\n    private static final Gauge.WithTwoLabels ACTIVE_GAUGE =\n            Gauge.name(\"skara_runner_active\").labels(\"bot\", \"work_item\").register();\n    /**\n     * Gauge that tracks the number of pending WorkItems for each kind\n     */\n    private static final Gauge.WithTwoLabels PENDING_GAUGE =\n            Gauge.name(\"skara_runner_pending\").labels(\"bot\", \"work_item\").register();\n\n    private void submitOrSchedule(WorkItem item) {\n        SCHEDULED_COUNTER.labels(item.botName(), item.workItemName()).inc();\n        synchronized (executor) {\n            for (var activeItem : active.keySet()) {\n                if (!activeItem.concurrentWith(item)) {\n\n                    Instant originalCreateTime = null;\n                    for (var pendingItem : pending.keySet()) {\n                        // If there are pending items of the same type that we cannot run concurrently with, replace them.\n                        if (item.replaces(pendingItem.item)) {\n                            log.finer(\"Discarding obsoleted item \" + pendingItem +\n                                              \" in favor of item \" + item);\n                            DISCARDED_COUNTER.labels(item.botName(), item.workItemName()).inc();\n                            removePending(pendingItem);\n                            originalCreateTime = pendingItem.createTime;\n                            // There can't be more than one\n                            break;\n                        }\n                    }\n\n                    log.fine(\"Adding pending item \" + item);\n                    addPending(new PendingWorkItem(item, originalCreateTime), activeItem);\n                    return;\n                }\n            }\n            log.fine(\"Submitting item \" + item);\n            submit(item);\n        }\n    }\n\n    /**\n     * Called to add a WorkItem to the pending queue\n     * @param pendingItem Item to queue\n     * @param activeItem Optional active item that this item is waiting for\n     */\n    private void addPending(PendingWorkItem pendingItem, WorkItem activeItem) {\n        pending.put(pendingItem, Optional.ofNullable(activeItem));\n        PENDING_GAUGE.labels(pendingItem.item.botName(), pendingItem.item.workItemName()).inc();\n        PENDING_COUNTER.labels(pendingItem.item.botName(), pendingItem.item.workItemName()).inc();\n    }\n\n    /**\n     * Called to remove an item from the pending queue.\n     */\n    private void removePending(PendingWorkItem pendingItem) {\n        pending.remove(pendingItem);\n        PENDING_GAUGE.labels(pendingItem.item.botName(), pendingItem.item.workItemName()).dec();\n    }\n\n    /**\n     * Called to submit a WorkItem for execution\n     */\n    private void submit(WorkItem item) {\n        RunnableWorkItem runnableWorkItem = new RunnableWorkItem(item);\n        executor.submit(runnableWorkItem);\n        active.put(item, runnableWorkItem);\n        ACTIVE_GAUGE.labels(item.botName(), item.workItemName()).inc();\n        SUBMITTED_COUNTER.labels(item.botName(), item.workItemName()).inc();\n    }\n\n    /**\n     * Called when a WorkItem is done executing\n     */\n    private void done(WorkItem item) {\n        active.remove(item);\n        ACTIVE_GAUGE.labels(item.botName(), item.workItemName()).dec();\n    }\n\n    private void drain(Duration timeout) throws TimeoutException {\n        Instant start = Instant.now();\n\n        while (Instant.now().isBefore(start.plus(timeout))) {\n            while (true) {\n                var head = (ScheduledFuture<?>) executor.getQueue().peek();\n                if (head != null) {\n                    log.fine(\"Waiting for future to complete\");\n                    try {\n                        head.get();\n                    } catch (InterruptedException | ExecutionException e) {\n                        log.log(Level.WARNING, \"Exception during queue drain\", e);\n                    }\n                } else {\n                    log.finest(\"Queue is now empty\");\n                    break;\n                }\n            }\n\n            synchronized (executor) {\n                if (pending.isEmpty() && active.isEmpty()) {\n                    log.fine(\"Nothing awaiting scheduling - drain is finished\");\n                    return;\n                } else {\n                    log.finest(\"Waiting for flighted tasks\");\n                }\n            }\n            try {\n                Thread.sleep(1);\n            } catch (InterruptedException e) {\n                log.log(Level.WARNING, \"Exception during queue drain\", e);\n            }\n        }\n\n        throw new TimeoutException();\n    }\n\n    private final BotRunnerConfiguration config;\n    private final List<Bot> bots;\n    private final ScheduledThreadPoolExecutor executor;\n    private final BotWatchdog botWatchdog;\n    private final Duration watchdogWarnTimeout;\n    private volatile boolean isReady;\n    private volatile boolean isHealthy;\n\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bot\");\n\n    public BotRunner(BotRunnerConfiguration config, List<Bot> bots) {\n        this.config = config;\n        this.bots = bots;\n\n        pending = new HashMap<>();\n        active = new HashMap<>();\n        scratchPaths = new LinkedList<>();\n\n        for (int i = 0; i < config.concurrency(); ++i) {\n            var folder = config.scratchFolder().resolve(\"scratch-\" + i);\n            scratchPaths.addLast(folder);\n        }\n\n        executor = new ScheduledThreadPoolExecutor(config.concurrency());\n        botWatchdog = new BotWatchdog(config.watchdogTimeout(), () -> isHealthy = false);\n        watchdogWarnTimeout = config.watchdogWarnTimeout();\n        isReady = false;\n        isHealthy = true;\n    }\n\n    boolean isReady() {\n        return isReady;\n    }\n\n    boolean isHealthy() {\n        return isHealthy;\n    }\n\n    private static final Gauge PERIODIC_CHECK_TIME_GAUGE =\n            Gauge.name(\"skara_runner_check_time_gauge\").register();\n    private static final Counter.WithOneLabel PERIODIC_CHECK_TIME =\n            Counter.name(\"skara_runner_check_time\").labels(\"bot\").register();\n\n    private void checkPeriodicItems() {\n        try (var __ = new LogContext(\"work_id\", String.valueOf(workIdCounter.incrementAndGet()))) {\n            Instant start = Instant.now();\n            log.log(Level.FINE, \"Start of checking for periodic items\", TaskPhases.BEGIN);\n            try {\n                for (var bot : bots) {\n                    Instant botStart = Instant.now();\n                    try (var ___ = new LogContext(\"bot\", bot.toString())) {\n                        log.fine(\"Start of checking for periodic items for \" + bot);\n                        var items = bot.getPeriodicItems();\n                        for (var item : items) {\n                            submitOrSchedule(item);\n                        }\n                    } catch (UncheckedRestException e) {\n                        // Log as WARNING to avoid triggering alarms. Failed REST calls are tracked\n                        // using metrics.\n                        log.log(Level.WARNING, \"RestException during periodic items checking: \" + e.getMessage(), e);\n                    } catch (RuntimeException e) {\n                        log.log(Level.SEVERE, \"Exception during periodic items checking: \" + e.getMessage(), e);\n                    } finally {\n                        var duration = Duration.between(botStart, Instant.now());\n                        log.log(Level.FINE, \"Checking for periodic items for \" + bot + \" took \" + duration, duration);\n                        PERIODIC_CHECK_TIME.labels(bot.name()).inc(duration.toMillis() / 1_000.0);\n                    }\n                }\n            } finally {\n                var duration = Duration.between(start, Instant.now());\n                log.log(Level.FINE, \"Checking periodic items took \" + duration,\n                        new Object[]{TaskPhases.END, duration});\n                PERIODIC_CHECK_TIME_GAUGE.set(duration.toMillis() / 1_000.0);\n            }\n        }\n    }\n\n    private void itemWatchdog() {\n        synchronized (executor) {\n            for (var activeRunnableItem : active.values()) {\n                Instant now = Instant.now();\n                var timeoutDuration = Duration.between(activeRunnableItem.timeoutWarningTime, now);\n                if (timeoutDuration.compareTo(watchdogWarnTimeout) > 0) {\n                    log.severe(\"Item \" + activeRunnableItem.item + \" with workId \" + activeRunnableItem.workId + \" has been active more than \" +\n                            Duration.between(activeRunnableItem.createTime, now) + \" - this may be an error!\");\n                    // Reset the counter to avoid continuous reporting - once every watchdogTimeout is enough\n                    activeRunnableItem.timeoutWarningTime = now;\n                }\n            }\n            // Inform the global watchdog that the scheduler is still executing items\n            log.fine(\"Pinging Watchdog\");\n            botWatchdog.ping();\n        }\n    }\n\n    void processWebhook(JSONValue request) {\n        try (var __ = new LogContext(\"work_id\", String.valueOf(workIdCounter.incrementAndGet()))) {\n            log.log(Level.FINE, \"Starting processing of incoming rest request\", TaskPhases.BEGIN);\n            log.fine(\"Request: \" + request);\n            try {\n                for (var bot : bots) {\n                    var items = bot.processWebHook(request);\n                    for (var item : items) {\n                        submitOrSchedule(item);\n                    }\n                }\n            } catch (RuntimeException e) {\n                log.log(Level.SEVERE, \"Exception during rest request processing: \" + e.getMessage(), e);\n            } finally {\n                log.log(Level.FINE, \"Done processing incoming rest request\", TaskPhases.END);\n            }\n        }\n    }\n\n    public void run() {\n        run(Duration.ofDays(10 * 365));\n    }\n\n    public void run(Duration timeout) {\n        log.info(\"Periodic task interval: \" + config.scheduledExecutionPeriod());\n        log.info(\"Concurrency: \" + config.concurrency());\n\n        HttpServer server = null;\n        var serverConfig = config.httpServer(this);\n        if (serverConfig.isPresent()) {\n            try {\n                var port = serverConfig.get().port();\n                var address = new InetSocketAddress(port);\n                server = HttpServer.create(address, 0);\n                server.setExecutor(null);\n                for (var context : serverConfig.get().contexts()) {\n                    server.createContext(context.path(), context.handler());\n                }\n                server.start();\n            } catch (IOException e) {\n                log.log(Level.WARNING, \"Failed to create HTTP server\", e);\n            }\n        }\n\n        isReady = true;\n\n        var schedulingInterval = config.scheduledExecutionPeriod().toMillis();\n        executor.scheduleAtFixedRate(this::itemWatchdog, 0, schedulingInterval, TimeUnit.MILLISECONDS);\n        executor.scheduleAtFixedRate(this::checkPeriodicItems, 0, schedulingInterval, TimeUnit.MILLISECONDS);\n\n        var cacheEvictionInterval = config.cacheEvictionInterval().toMillis();\n        executor.scheduleAtFixedRate(RestRequest::evictOldCacheData, cacheEvictionInterval,\n                cacheEvictionInterval, TimeUnit.MILLISECONDS);\n\n        try {\n            executor.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS);\n        } catch (InterruptedException e) {\n            e.printStackTrace();\n        }\n\n        if (server != null) {\n            server.stop(0);\n        }\n        executor.shutdown();\n    }\n\n    public void runOnce(Duration timeout) throws TimeoutException {\n        log.info(\"Starting BotRunner execution, will run once\");\n        log.info(\"Timeout: \" + timeout);\n        log.info(\"Concurrency: \" + config.concurrency());\n\n        var periodics = executor.submit(this::checkPeriodicItems);\n        try {\n            log.fine(\"Make sure periodics execute at least once\");\n            periodics.get();\n            log.fine(\"Periodics have now run\");\n        } catch (InterruptedException e) {\n            throw new BotRunnerError(\"Interrupted\", e);\n        } catch (ExecutionException e) {\n            throw new BotRunnerError(\"Execution error\", e);\n        }\n        log.fine(\"Waiting for all spawned tasks\");\n        drain(timeout);\n\n        log.fine(\"Done waiting for all tasks\");\n        executor.shutdown();\n    }\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/BotRunnerConfiguration.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport org.openjdk.skara.ci.ContinuousIntegration;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.JSONObject;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.vcs.Branch;\nimport org.openjdk.skara.vcs.VCS;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.nio.file.*;\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.function.BiFunction;\nimport java.util.logging.Logger;\n\nimport com.sun.net.httpserver.HttpHandler;\n\npublic class BotRunnerConfiguration {\n    private final Logger log;\n    private final JSONObject config;\n    private final Map<String, Forge> repositoryHosts;\n    private final Map<String, IssueTracker> issueHosts;\n    private final Map<String, ContinuousIntegration> continuousIntegrations;\n    private final Map<String, HostedRepository> repositories;\n\n    private BotRunnerConfiguration(JSONObject config, Path cwd) throws ConfigurationError {\n        this.config = config;\n        log = Logger.getLogger(\"org.openjdk.skara.bot\");\n\n        repositoryHosts = parseRepositoryHosts(config, cwd);\n        issueHosts = parseIssueHosts(config, cwd);\n        continuousIntegrations = parseContinuousIntegrations(config, cwd);\n        repositories = parseRepositories(config);\n    }\n\n    private Map<String, Forge> parseRepositoryHosts(JSONObject config, Path cwd) throws ConfigurationError {\n        Map<String, Forge> ret = new HashMap<>();\n\n        if (!config.contains(\"forges\")) {\n            return ret;\n        }\n\n        for (var entry : config.get(\"forges\").fields()) {\n            if (entry.value().contains(\"gitlab\")) {\n                var gitlab = entry.value().get(\"gitlab\");\n                var uri = URIBuilder.base(gitlab.get(\"url\").asString()).build();\n                var pat = new Credential(gitlab.get(\"username\").asString(), gitlab.get(\"pat\").asString());\n                ret.put(entry.name(), Forge.from(\"gitlab\", uri, pat, gitlab.asObject()));\n            } else if (entry.value().contains(\"github\")) {\n                var github = entry.value().get(\"github\");\n                URI uri;\n                if (github.contains(\"url\")) {\n                    uri = URIBuilder.base(github.get(\"url\").asString()).build();\n                } else {\n                    uri = URIBuilder.base(\"https://github.com/\").build();\n                }\n\n                if (github.contains(\"app\")) {\n                    var keyFile = cwd.resolve(github.get(\"app\").get(\"key\").asString());\n                    try {\n                        var keyContents = Files.readString(keyFile);\n                        var pat = new Credential(github.get(\"app\").get(\"id\").asString() + \";\" +\n                                github.get(\"app\").get(\"installation\").asString(),\n                                keyContents);\n                        ret.put(entry.name(), Forge.from(\"github\", uri, pat, github.asObject()));\n                    } catch (IOException e) {\n                        throw new ConfigurationError(\"Cannot find key file: \" + keyFile);\n                    }\n                } else if (github.contains(\"username\")) {\n                    var pat = new Credential(github.get(\"username\").asString(), github.get(\"pat\").asString());\n                    ret.put(entry.name(), Forge.from(\"github\", uri, pat, github.asObject()));\n                } else {\n                    ret.put(entry.name(), Forge.from(\"github\", uri, github.asObject()));\n                }\n            } else if (entry.value().contains(\"bitbucket\")) {\n                var bitbucket = entry.value().get(\"bitbucket\");\n                var uri = URIBuilder.base(bitbucket.get(\"url\").asString()).build();\n                var credential = new Credential(bitbucket.get(\"username\").asString(), bitbucket.get(\"pat\").asString());\n                ret.put(entry.name(), Forge.from(\"bitbucket\", uri, credential, bitbucket.asObject()));\n            } else {\n                throw new ConfigurationError(\"Host \" + entry.name());\n            }\n        }\n\n        return ret;\n    }\n\n    private Map<String, IssueTracker> parseIssueHosts(JSONObject config, Path cwd) throws ConfigurationError {\n        Map<String, IssueTracker> ret = new HashMap<>();\n\n        if (!config.contains(\"issuetrackers\")) {\n            return ret;\n        }\n\n        for (var entry : config.get(\"issuetrackers\").fields()) {\n            if (entry.value().contains(\"jira\")) {\n                var jira = entry.value().get(\"jira\");\n                var uri = URIBuilder.base(jira.get(\"url\").asString()).build();\n                Credential credential = null;\n                if (jira.contains(\"username\")) {\n                    credential = new Credential(jira.get(\"username\").asString(), jira.get(\"password\").asString());\n                }\n                ret.put(entry.name(), IssueTracker.from(\"jira\", uri, credential, jira.asObject()));\n            } else {\n                throw new ConfigurationError(\"Host \" + entry.name());\n            }\n        }\n\n        return ret;\n    }\n\n    private Map<String, ContinuousIntegration> parseContinuousIntegrations(JSONObject config, Path cwd) throws ConfigurationError {\n        Map<String, ContinuousIntegration> ret = new HashMap<>();\n\n        if (!config.contains(\"ci\")) {\n            return ret;\n        }\n\n        for (var entry : config.get(\"ci\").fields()) {\n            var url = entry.value().get(\"url\").asString();\n            var ci = ContinuousIntegration.from(URI.create(url), entry.value().asObject());\n            if (ci.isPresent()) {\n                ret.put(entry.name(), ci.get());\n            } else {\n                throw new ConfigurationError(\"No continuous integration named with url: \" + url);\n            }\n        }\n\n        return ret;\n    }\n\n    private Map<String, HostedRepository> parseRepositories(JSONObject config) throws ConfigurationError {\n        Map<String, HostedRepository> ret = new HashMap<>();\n\n        if (!config.contains(\"repositories\")) {\n            return ret;\n        }\n\n        for (var entry : config.get(\"repositories\").fields()) {\n            var hostName = entry.value().get(\"host\").asString();\n            if (!repositoryHosts.containsKey(hostName)) {\n                throw new ConfigurationError(\"Repository \" + entry.name() + \" uses undefined host '\" + hostName + \"'\");\n            }\n            var host = repositoryHosts.get(hostName);\n            var repo = host.repository(entry.value().get(\"repository\").asString()).orElseThrow(() ->\n                    new ConfigurationError(\"Repository \" + entry.value().get(\"repository\").asString() + \" is not available at \" + hostName)\n            );\n            ret.put(entry.name(), repo);\n        }\n\n        return ret;\n    }\n\n    private static class RepositoryEntry {\n        HostedRepository repository;\n        String ref;\n    }\n\n    private RepositoryEntry parseRepositoryEntry(String entry) throws ConfigurationError {\n        var ret = new RepositoryEntry();\n        var refSeparatorIndex = entry.indexOf(':');\n        if (refSeparatorIndex >= 0) {\n            ret.ref = entry.substring(refSeparatorIndex + 1);\n            entry = entry.substring(0, refSeparatorIndex);\n        }\n        var hostSeparatorIndex = entry.indexOf('/');\n        if (hostSeparatorIndex >= 0) {\n            var hostName = entry.substring(0, hostSeparatorIndex);\n            var host = repositoryHosts.get(hostName);\n            if (!repositoryHosts.containsKey(hostName)) {\n                throw new ConfigurationError(\"Repository entry \" + entry + \" uses undefined host '\" + hostName + \"'\");\n            }\n            var repositoryName = entry.substring(hostSeparatorIndex + 1);\n            ret.repository = host.repository(repositoryName).orElseThrow(() ->\n                    new ConfigurationError(\"Repository \" + repositoryName + \" is not available at \" + hostName)\n            );\n        } else {\n            if (!repositories.containsKey(entry)) {\n                throw new ConfigurationError(\"Repository \" + entry + \" is not defined!\");\n            }\n            ret.repository = repositories.get(entry);\n        }\n\n        if (ret.ref == null) {\n            ret.ref = Branch.defaultFor(ret.repository.repositoryType()).name();\n        }\n\n        return ret;\n    }\n\n    private IssueProject parseIssueProjectEntry(String entry) throws ConfigurationError {\n        var hostSeparatorIndex = entry.indexOf('/');\n        if (hostSeparatorIndex >= 0) {\n            var hostName = entry.substring(0, hostSeparatorIndex);\n            var host = issueHosts.get(hostName);\n            if (!issueHosts.containsKey(hostName)) {\n                throw new ConfigurationError(\"Issue project entry \" + entry + \" uses undefined host '\" + hostName + \"'\");\n            }\n            var issueProjectName = entry.substring(hostSeparatorIndex + 1);\n            return host.project(issueProjectName);\n        } else {\n            throw new ConfigurationError(\"Malformed issue project entry\");\n        }\n    }\n\n    public static BotRunnerConfiguration parse(JSONObject config, Path cwd) throws ConfigurationError {\n        return new BotRunnerConfiguration(config, cwd);\n    }\n\n    public static BotRunnerConfiguration parse(JSONObject config) throws ConfigurationError {\n        return parse(config, Paths.get(\".\"));\n    }\n\n    public BotConfiguration perBotConfiguration(String botName) throws ConfigurationError {\n        if (!config.contains(botName)) {\n            throw new ConfigurationError(\"No configuration for bot name: \" + botName);\n        }\n\n        return new BotConfiguration() {\n            @Override\n            public Path storageFolder() {\n                if (!config.contains(\"storage\") || !config.get(\"storage\").contains(\"path\")) {\n                    try {\n                        return Files.createTempDirectory(\"storage-\" + botName);\n                    } catch (IOException e) {\n                        throw new UncheckedIOException(e);\n                    }\n                }\n                return Paths.get(config.get(\"storage\").get(\"path\").asString()).resolve(botName);\n            }\n\n            @Override\n            public HostedRepository repository(String name) {\n                try {\n                    var entry = parseRepositoryEntry(name);\n                    return entry.repository;\n                } catch (ConfigurationError configurationError) {\n                    throw new RuntimeException(\"Couldn't find repository with name: \" + name, configurationError);\n                }\n            }\n\n            @Override\n            public IssueTracker issueTracker(String name) {\n                if (!issueHosts.containsKey(name)) {\n                    throw new RuntimeException(\"Couldn't find issue tracker with name: \" + name);\n                }\n                return issueHosts.get(name);\n            }\n\n            @Override\n            public IssueProject issueProject(String name) {\n                try {\n                    return parseIssueProjectEntry(name);\n                } catch (ConfigurationError configurationError) {\n                    throw new RuntimeException(\"Couldn't find issue project with name: \" + name, configurationError);\n                }\n            }\n\n            @Override\n            public ContinuousIntegration continuousIntegration(String name) {\n                if (continuousIntegrations.containsKey(name)) {\n                    return continuousIntegrations.get(name);\n                }\n                throw new RuntimeException(\"Couldn't find continuous integration with name: \" + name);\n            }\n\n            @Override\n            public String repositoryRef(String name) {\n                try {\n                    var entry = parseRepositoryEntry(name);\n                    return entry.ref;\n                } catch (ConfigurationError configurationError) {\n                    throw new RuntimeException(\"Couldn't find repository with name: \" + name, configurationError);\n                }\n            }\n\n            @Override\n            public String repositoryName(String name) {\n                var refIndex = name.indexOf(':');\n                if (refIndex >= 0) {\n                    name = name.substring(0, refIndex);\n                }\n                var orgIndex = name.lastIndexOf('/');\n                if (orgIndex >= 0) {\n                    name = name.substring(orgIndex + 1);\n                }\n                return name;\n            }\n\n            @Override\n            public JSONObject specific() {\n                return config.get(botName).asObject();\n            }\n        };\n    }\n\n    /**\n     * The amount of time to wait between each invocation of Bot.getPeriodicItems.\n     * @return\n     */\n    Duration scheduledExecutionPeriod() {\n        if (!config.contains(\"runner\") || !config.get(\"runner\").contains(\"interval\")) {\n            log.info(\"No WorkItem invocation period defined, using default value\");\n            return Duration.ofSeconds(10);\n        } else {\n            return Duration.parse(config.get(\"runner\").get(\"interval\").asString());\n        }\n    }\n\n    /**\n     * The amount of time to wait between runs of the RestResponseCache evictions.\n     * @return\n     */\n    Duration cacheEvictionInterval() {\n        if (!config.contains(\"runner\") || !config.get(\"runner\").contains(\"cache_eviction_interval\")) {\n            var defaultValue = Duration.ofMinutes(5);\n            log.info(\"No cache eviction interval defined, using default value \" + defaultValue);\n            return defaultValue;\n        } else {\n            return Duration.parse(config.get(\"runner\").get(\"cache_eviction_interval\").asString());\n        }\n    }\n\n    /**\n     * Number of WorkItems to execute in parallel.\n     * @return\n     */\n    Integer concurrency() {\n        if (!config.contains(\"runner\") || !config.get(\"runner\").contains(\"concurrency\")) {\n            log.info(\"WorkItem concurrency not defined, using default value\");\n            return 2;\n        } else {\n            return config.get(\"runner\").get(\"concurrency\").asInt();\n        }\n    }\n\n    /**\n     * Folder that WorkItems may use to store temporary data.\n     * @return\n     */\n    Path scratchFolder() {\n        if (!config.contains(\"scratch\") || !config.get(\"scratch\").contains(\"path\")) {\n            try {\n                log.warning(\"No scratch folder defined, creating a temporary folder\");\n                return Files.createTempDirectory(\"botrunner\");\n            } catch (IOException e) {\n                throw new UncheckedIOException(e);\n            }\n        }\n        return Paths.get(config.get(\"scratch\").get(\"path\").asString());\n    }\n\n    static class HttpContextConfiguration {\n        private final String path;\n        private final HttpHandler handler;\n\n        private HttpContextConfiguration(String path, HttpHandler handler) {\n            this.path = path;\n            this.handler = handler;\n        }\n\n        String path() {\n            return path;\n        }\n\n        HttpHandler handler() {\n            return handler;\n        }\n    }\n\n    static class HttpServerConfiguration {\n        private final int port;\n        private final List<HttpContextConfiguration> contexts;\n\n        private HttpServerConfiguration(int port, List<HttpContextConfiguration> contexts) {\n            this.port = port;\n            this.contexts = contexts;\n        }\n\n        int port() {\n            return port;\n        }\n\n        List<HttpContextConfiguration> contexts() {\n            return contexts;\n        }\n    }\n\n    Optional<HttpServerConfiguration> httpServer(BotRunner runner) {\n        if (!config.contains(\"http-server\")) {\n            return Optional.empty();\n        }\n\n        Map<String, BiFunction<BotRunner, JSONObject, HttpHandler>> factories = Map.of(\n            WebhookHandler.name(), WebhookHandler::create,\n            MetricsHandler.name(), MetricsHandler::create,\n            ReadinessHandler.name(), ReadinessHandler::create,\n            LivenessHandler.name(), LivenessHandler::create,\n            ProfileHandler.name(), ProfileHandler::create,\n            VersionHandler.name(), VersionHandler::create\n        );\n        var contexts = new ArrayList<HttpContextConfiguration>();\n        var port = config.get(\"http-server\").get(\"port\").asInt();\n        for (var field : config.get(\"http-server\").fields()) {\n            if (field.name().startsWith(\"/\")) {\n                var path = field.name();\n                var type = field.value().get(\"type\").asString();\n                if (!factories.containsKey(type)) {\n                    throw new RuntimeException(\"Unknown kind of HTTP handler: \" + type);\n                }\n                var handler = factories.get(type).apply(runner, field.value().asObject());\n                contexts.add(new HttpContextConfiguration(path, handler));\n            }\n        }\n\n        return Optional.of(new HttpServerConfiguration(port, contexts));\n    }\n\n    Duration watchdogTimeout() {\n        if (!config.contains(\"runner\") || !config.get(\"runner\").contains(\"watchdog\")) {\n            log.info(\"No WorkItem watchdog timeout defined, using default value\");\n            return Duration.ofMinutes(30);\n        } else {\n            return Duration.parse(config.get(\"runner\").get(\"watchdog\").asString());\n        }\n    }\n\n    Duration watchdogWarnTimeout() {\n        if (!config.contains(\"runner\") || !config.get(\"runner\").contains(\"watchdog_warn\")) {\n            log.info(\"No WorkItem watchdog_warn timeout defined, using watchdog value\");\n            return watchdogTimeout();\n        } else {\n            return Duration.parse(config.get(\"runner\").get(\"watchdog_warn\").asString());\n        }\n    }\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/BotTaskAggregationHandler.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.logging.*;\n\npublic abstract class BotTaskAggregationHandler extends StreamHandler {\n\n    private static class ThreadLogs {\n        boolean isPublishing;\n        boolean inTask;\n        List<LogRecord> logs;\n\n        ThreadLogs() {\n            isPublishing = false;\n            clear();\n        }\n\n        void clear() {\n            inTask = false;\n            logs = new ArrayList<>();\n        }\n    }\n\n    private final Map<Long, ThreadLogs> threadLogs;\n    private final Logger log;\n    // Should this class handle log level filtering or leave that to the subclass\n    private final boolean filterOnLevel;\n\n    public BotTaskAggregationHandler(boolean filterOnLevel) {\n        this.filterOnLevel = filterOnLevel;\n        threadLogs = new ConcurrentHashMap<>();\n        log = Logger.getLogger(\"org.openjdk.skara.bot\");\n    }\n\n    private boolean hasMarker(LogRecord record, BotRunner.TaskPhases marker) {\n        if (record.getParameters() == null) {\n            return false;\n        }\n        return Arrays.asList(record.getParameters()).contains(marker);\n    }\n\n    @Override\n    public final void publish(LogRecord record) {\n        var newEntry = new ThreadLogs();\n        var threadEntry = threadLogs.putIfAbsent(record.getLongThreadID(), newEntry);\n        if (threadEntry == null) {\n            threadEntry = newEntry;\n        }\n\n        // Avoid potential recursive log output\n        if (threadEntry.isPublishing) {\n            return;\n        }\n        threadEntry.isPublishing = true;\n\n        try {\n            if (!threadEntry.inTask) {\n                if (!hasMarker(record, BotRunner.TaskPhases.BEGIN)) {\n                    if (!filterOnLevel || record.getLevel().intValue() >= getLevel().intValue()) {\n                        publishSingle(record);\n                    }\n                    return;\n                }\n                threadEntry.inTask = true;\n            }\n            if (!filterOnLevel || record.getLevel().intValue() >= getLevel().intValue()) {\n                threadEntry.logs.add(record);\n            }\n\n            if (hasMarker(record, BotRunner.TaskPhases.END)) {\n                publishAggregated(threadEntry.logs);\n                threadEntry.clear();\n            }\n        }\n        catch (RuntimeException e) {\n            log.log(Level.SEVERE, \"Exception during task notification posting: \" + e.getMessage(), e);\n        } finally {\n            threadEntry.isPublishing = false;\n        }\n    }\n\n    public abstract void publishAggregated(List<LogRecord> task);\n    public abstract void publishSingle(LogRecord record);\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/BotWatchdog.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport java.time.Duration;\n\npublic class BotWatchdog {\n    private final Thread watchThread;\n    private final Duration maxWait;\n    private final Runnable callBack;\n    private volatile boolean hasBeenPinged = false;\n\n    private void threadMain() {\n        while (true) {\n            try {\n                Thread.sleep(maxWait);\n                if (!hasBeenPinged) {\n                    System.out.println(\"No watchdog ping detected for \" + maxWait + \" - exiting...\");\n                    callBack.run();\n                    System.exit(1);\n                }\n                hasBeenPinged = false;\n            } catch (InterruptedException ignored) {\n            }\n        }\n    }\n\n    BotWatchdog(Duration maxWait, Runnable callBack) {\n        this.maxWait = maxWait;\n        this.callBack = callBack;\n        watchThread = new Thread(this::threadMain);\n        watchThread.setName(\"BotWatchdog\");\n        watchThread.setDaemon(true);\n        watchThread.start();\n    }\n\n    public void ping() {\n        hasBeenPinged = true;\n    }\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/ConfigurationError.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\npublic class ConfigurationError extends Exception {\n    ConfigurationError(String message) {\n        super(message);\n    }\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/LivenessHandler.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport org.openjdk.skara.json.JSONObject;\n\nimport com.sun.net.httpserver.*;\n\nimport java.io.IOException;\nimport java.util.logging.Logger;\n\nclass LivenessHandler implements HttpHandler {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bot\");\n    private final BotRunner runner;\n\n    LivenessHandler(BotRunner runner) {\n        this.runner = runner;\n    }\n\n    @Override\n    public void handle(HttpExchange exchange) throws IOException {\n        if (runner.isHealthy()) {\n            exchange.sendResponseHeaders(200, 0);\n            exchange.getResponseBody().close();\n        } else {\n            exchange.sendResponseHeaders(500, 0);\n            exchange.getResponseBody().close();\n        }\n    }\n\n    static LivenessHandler create(BotRunner runner, JSONObject configuration) {\n        return new LivenessHandler(runner);\n    }\n\n    static String name() {\n        return \"liveness\";\n    }\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/LogContext.java",
    "content": "package org.openjdk.skara.bot;\n\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.logging.Logger;\n\n/**\n * A LogContext is used to temporarily add extra log metadata in the current thread.\n * It should be initiated with a try-with-resources construct. The variable itself\n * is never used, we only want the controlled automatic close at the end of the try\n * block. Typically name the variable __. Example:\n *\n * try (var __ = new LogContext(\"foo\", \"bar\")) {\n *     // some code that logs stuff\n * }\n */\npublic class LogContext implements AutoCloseable {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bot\");\n    private final Map<String, String> context = new HashMap<>();\n\n    public LogContext(String key, String value) {\n        this.init(Map.of(key, value));\n    }\n\n    public LogContext(Map<String, String> ctx) {\n        this.init(ctx);\n    }\n\n    private void init(Map<String, String> newContext) {\n        for (var entry : newContext.entrySet()) {\n            String currentValue = LogContextMap.get(entry.getKey());\n            if (currentValue != null) {\n                if (!currentValue.equals(entry.getValue())) {\n                    log.severe(\"Tried to override the current LogContext value: \" + currentValue\n                            + \" for \" + entry.getKey() + \" with a different value: \" + entry.getValue());\n                }\n            } else {\n                this.context.put(entry.getKey(), entry.getValue());\n                LogContextMap.put(entry.getKey(), entry.getValue());\n            }\n        }\n\n    }\n\n    public void close() {\n        this.context.forEach((key, value) -> {\n            LogContextMap.remove(key);\n        });\n    }\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/LogContextMap.java",
    "content": "package org.openjdk.skara.bot;\n\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.Set;\n\n/**\n * This class holds a static thread local hashmap to store temporary log\n * metadata which our custom StreamHandlers can pick up and include in log\n * messages.\n */\npublic class LogContextMap {\n\n    private static final ThreadLocal<HashMap<String, String>> threadContextMap = new ThreadLocal<>();\n\n    public static void put(String key, String value) {\n        if (threadContextMap.get() == null) {\n            threadContextMap.set(new HashMap<>());\n        }\n        var map = threadContextMap.get();\n        map.put(key, value);\n    }\n\n    public static String get(String key) {\n        if (threadContextMap.get() != null) {\n            return threadContextMap.get().get(key);\n        } else {\n            return null;\n        }\n    }\n\n    public static String remove(String key) {\n        if (threadContextMap.get() != null) {\n            return threadContextMap.get().remove(key);\n        } else {\n            return null;\n        }\n    }\n\n    public static Set<Map.Entry<String, String>> entrySet() {\n        if (threadContextMap.get() != null) {\n            return threadContextMap.get().entrySet();\n        } else {\n            return Collections.emptySet();\n        }\n    }\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/MetricsHandler.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport com.sun.net.httpserver.*;\nimport org.openjdk.skara.json.*;\n\nimport org.openjdk.skara.metrics.*;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.util.logging.Logger;\nimport java.time.ZonedDateTime;\n\nclass MetricsHandler implements HttpHandler {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bot\");\n    private final Exporter exporter;\n\n    private MetricsHandler(Exporter exporter) {\n        this.exporter = exporter;\n    }\n\n    @Override\n    public void handle(HttpExchange exchange) throws IOException {\n        var metrics = CollectorRegistry.defaultRegistry().scrape();\n        var response = exporter.export(metrics);\n        exchange.sendResponseHeaders(200, response.length());\n        var output = exchange.getResponseBody();\n        output.write(response.getBytes(StandardCharsets.UTF_8));\n        output.close();\n    }\n\n    static MetricsHandler create(BotRunner runner, JSONObject configuration) {\n        return new MetricsHandler(new PrometheusExporter());\n    }\n\n    static String name() {\n        return \"metrics\";\n    }\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/ProfileHandler.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport org.openjdk.skara.json.JSONObject;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.logging.*;\nimport com.sun.net.httpserver.*;\nimport jdk.jfr.*;\nimport java.text.ParseException;\nimport java.util.concurrent.locks.ReentrantLock;\n\nclass ProfileHandler implements HttpHandler {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bot\");\n    private final Path configurationPath;\n    private final int maxDuration;\n    private final String token;\n    private final ReentrantLock lock = new ReentrantLock();\n\n    private ProfileHandler(Path configurationPath, int maxDuration, String token) {\n        this.configurationPath = configurationPath;\n        this.maxDuration = maxDuration;\n        this.token = token;\n    }\n\n    private static Map<String, String> parameters(HttpExchange exchange) {\n        var query = exchange.getRequestURI().getQuery();\n        var parts = query.split(\"&\");\n        var result = new HashMap<String, String>();\n        for (var part : parts) {\n            var keyAndValue = part.split(\"=\");\n            result.put(keyAndValue[0], keyAndValue[1]);\n        }\n        return result;\n    }\n\n    private void handleLocked(HttpExchange exchange) throws IOException {\n        var params = parameters(exchange);\n        var seconds = params.getOrDefault(\"seconds\", \"30\");\n        var configurationName = params.getOrDefault(\"configuration\", \"profile\");\n\n        Configuration configuration = null;\n        try {\n            configuration = Configuration.create(configurationPath);\n        } catch (ParseException e) {\n            log.log(Level.WARNING, \"Could not get JFR configuration\", e);\n            exchange.sendResponseHeaders(500, 0);\n            exchange.getResponseBody().close();\n        }\n\n        log.info(\"Profiling for \" + seconds + \" seconds with configuration \" + configurationName);\n        var recording = new Recording(configuration);\n        recording.start();\n\n        try {\n            var duration = Integer.parseInt(seconds);\n            if (duration > maxDuration) {\n                duration = maxDuration;\n            }\n            Thread.sleep(duration * 1000);\n        } catch (InterruptedException e) {\n            log.log(Level.WARNING, \"Thread interrupted when sleeping\", e);\n            exchange.sendResponseHeaders(500, 0);\n            exchange.getResponseBody().close();\n        }\n\n        recording.stop();\n        var path = Files.createTempFile(\"recording\", \"jfr\");\n        recording.dump(path);\n\n        var buffer = new byte[4096];\n        exchange.sendResponseHeaders(200, Files.size(path));\n        try (var output = exchange.getResponseBody(); var stream = Files.newInputStream(path)) {\n            while (true) {\n                var read = stream.read(buffer);\n                if (read == -1) {\n                    break;\n                }\n                output.write(buffer, 0, read);\n            }\n        } catch (Throwable t) {\n            log.log(Level.WARNING, \"Could not send JFR recording\", t);\n        } finally {\n            Files.deleteIfExists(path);\n        }\n    }\n\n    @Override\n    public void handle(HttpExchange exchange) throws IOException {\n        var authHeader = exchange.getRequestHeaders().getFirst(\"Authorization\");\n        if (authHeader == null) {\n            log.log(Level.WARNING, \"Authorization HTTP header missing\");\n            exchange.sendResponseHeaders(401, 0);\n            exchange.getResponseBody().close();\n            return;\n        }\n        var authParts = authHeader.split(\" \");\n        if (authParts.length != 2 || !authParts[0].equals(\"token\")) {\n            log.log(Level.WARNING, \"Authorization HTTP header has wrong format\");\n            exchange.sendResponseHeaders(401, 0);\n            exchange.getResponseBody().close();\n            return;\n        }\n        if (!authParts[1].equals(token)) {\n            log.log(Level.WARNING, \"Wrong authorization token: \" + authParts[1]);\n            exchange.sendResponseHeaders(401, 0);\n            exchange.getResponseBody().close();\n            return;\n        }\n\n        // Only allow one recording at a time.\n        lock.lock();\n        try {\n            handleLocked(exchange);\n        } finally {\n            lock.unlock();\n        }\n    }\n\n    static ProfileHandler create(BotRunner runner, JSONObject configuration) {\n        var configurationPath = Path.of(configuration.get(\"configuration\").asString());\n        var maxDuration = configuration.get(\"max-duration\").asInt();\n        var token = configuration.get(\"token\").asString();\n        return new ProfileHandler(configurationPath, maxDuration, token);\n    }\n\n    static String name() {\n        return \"profile\";\n    }\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/ReadinessHandler.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport org.openjdk.skara.json.JSONObject;\n\nimport com.sun.net.httpserver.*;\n\nimport java.io.IOException;\nimport java.util.logging.Logger;\n\nclass ReadinessHandler implements HttpHandler {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bot\");\n    private final BotRunner runner;\n\n    ReadinessHandler(BotRunner runner) {\n        this.runner = runner;\n    }\n\n    @Override\n    public void handle(HttpExchange exchange) throws IOException {\n        if (runner.isReady()) {\n            exchange.sendResponseHeaders(200, 0);\n            exchange.getResponseBody().close();\n        } else {\n            exchange.sendResponseHeaders(404, 0);\n            exchange.getResponseBody().close();\n        }\n    }\n\n    static ReadinessHandler create(BotRunner runner, JSONObject configuration) {\n        return new ReadinessHandler(runner);\n    }\n\n    static String name() {\n        return \"readiness\";\n    }\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/VersionHandler.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport org.openjdk.skara.json.JSONObject;\nimport org.openjdk.skara.version.Version;\n\nimport com.sun.net.httpserver.*;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.util.logging.Logger;\n\nclass VersionHandler implements HttpHandler {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bot\");\n\n    @Override\n    public void handle(HttpExchange exchange) throws IOException {\n        var version = Version.fromManifest();\n        if (version.isPresent()) {\n            var bytes = version.get().getBytes(StandardCharsets.UTF_8);\n            exchange.sendResponseHeaders(200, bytes.length);\n            exchange.getResponseBody().write(bytes);\n            exchange.getResponseBody().close();\n        } else {\n            exchange.sendResponseHeaders(500, 0);\n            exchange.getResponseBody().close();\n        }\n    }\n\n    static VersionHandler create(BotRunner runner, JSONObject configuration) {\n        return new VersionHandler();\n    }\n\n    static String name() {\n        return \"version\";\n    }\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/WebhookHandler.java",
    "content": "/*\n * Copyright (c) 2018, 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport com.sun.net.httpserver.*;\nimport org.openjdk.skara.json.*;\n\nimport java.io.*;\nimport java.nio.charset.StandardCharsets;\nimport java.util.logging.Level;\nimport java.util.logging.Logger;\n\nclass WebhookHandler implements HttpHandler {\n    private final static Logger log = Logger.getLogger(\"org.openjdk.skara.bot\");\n    private final BotRunner runner;\n\n    private WebhookHandler(BotRunner runner) {\n        this.runner = runner;\n    }\n\n    @Override\n    public void handle(HttpExchange exchange) throws IOException {\n        var input = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);\n\n        JSONValue json = null;\n        try {\n            json = JSON.parse(input);\n        } catch (Exception e) {\n            log.log(Level.WARNING, \"Failed to parse incoming request: \" + input, e);\n            exchange.sendResponseHeaders(400, 0);\n            exchange.getResponseBody().close();\n            return;\n        }\n\n        // Reply immediately\n        var response = \"{}\";\n        exchange.sendResponseHeaders(200, response.length());\n        var output = exchange.getResponseBody();\n        output.write(response.getBytes(StandardCharsets.UTF_8));\n        output.close();\n\n        runner.processWebhook(json);\n    }\n\n    static String name() {\n        return \"webhook\";\n    }\n\n    static WebhookHandler create(BotRunner runner, JSONObject configuration) {\n        return new WebhookHandler(runner);\n    }\n}\n"
  },
  {
    "path": "bot/src/main/java/org/openjdk/skara/bot/WorkItem.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport java.nio.file.Path;\nimport java.util.*;\n\npublic interface WorkItem {\n    /**\n     * Return true if this item can run concurrently with <code>other</code>, otherwise false.\n     * @param other\n     * @return\n     */\n    boolean concurrentWith(WorkItem other);\n\n    /**\n     * Returns true if this item should replace the other item in the queue. By default\n     * this is true if both items are of the same type, and cannot run concurrently with\n     * each other. In some cases we need a more specific condition.\n     */\n    default boolean replaces(WorkItem other) {\n        return this.getClass().equals(other.getClass()) && !concurrentWith(other);\n    }\n\n    /**\n     * Execute the appropriate tasks with the provided scratch folder. Optionally return follow-up work items\n     * that will be scheduled for execution.\n     * @param scratchPath\n     * @return A collection of follow-up work items, allowed to be empty (or null) if none are needed.\n     */\n    Collection<WorkItem> run(Path scratchPath);\n\n    String botName();\n    String workItemName();\n\n    /**\n     * The BotRunner will catch <code>RuntimeException</code>s, implementing this method allows a WorkItem to\n     * perform additional cleanup if necessary (avoiding the need for catching and rethrowing the exception).\n     * @param e\n     */\n    default void handleRuntimeException(RuntimeException e) {}\n}\n"
  },
  {
    "path": "bot/src/test/java/org/openjdk/skara/bot/BotRunnerConfigurationTests.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport org.openjdk.skara.json.JSON;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.nio.file.Path;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass BotRunnerConfigurationTests {\n    @Test\n    void storageFolder() throws ConfigurationError {\n        var input = JSON.object().put(\"storage\", JSON.object().put(\"path\", \"/x\"))\n                        .put(\"xbot\", JSON.object());\n        var cfg = BotRunnerConfiguration.parse(input);\n        var botCfg = cfg.perBotConfiguration(\"xbot\");\n\n        assertEquals(Path.of(\"/x/xbot\"), botCfg.storageFolder());\n    }\n\n    @Test\n    void parseHost() throws ConfigurationError {\n        var input = JSON.object()\n                        .put(\"xbot\",\n                             JSON.object().put(\"repository\", \"test/x/y\"));\n        var cfg = BotRunnerConfiguration.parse(input);\n        var botCfg = cfg.perBotConfiguration(\"xbot\");\n\n        var error = assertThrows(RuntimeException.class, () -> botCfg.repository(\"test/x/y\"));\n        assertEquals(\"Repository entry test/x/y uses undefined host 'test'\", error.getCause().getMessage());\n    }\n\n    @Test\n    void parseRef() throws ConfigurationError {\n        var input = JSON.object()\n                        .put(\"xbot\",\n                             JSON.object().put(\"repository\", \"test/x/y:z\"));\n        var cfg = BotRunnerConfiguration.parse(input);\n        var botCfg = cfg.perBotConfiguration(\"xbot\");\n\n        var error = assertThrows(RuntimeException.class, () -> botCfg.repositoryRef(\"test/x/y:z\"));\n        assertEquals(\"Repository entry test/x/y uses undefined host 'test'\", error.getCause().getMessage());\n    }\n\n    @Test\n    void parseName() throws ConfigurationError {\n        var empty = JSON.object().put(\"xbot\", JSON.object());\n        var cfg = BotRunnerConfiguration.parse(empty);\n        assertEquals(\"repo\", cfg.perBotConfiguration(\"xbot\").repositoryName(\"repo\"));\n        assertEquals(\"repo\", cfg.perBotConfiguration(\"xbot\").repositoryName(\"host/org/repo\"));\n        assertEquals(\"repo\", cfg.perBotConfiguration(\"xbot\").repositoryName(\"host/org/repo:ref\"));\n        assertEquals(\"repo\", cfg.perBotConfiguration(\"xbot\").repositoryName(\"host/org/repo:nested/ref\"));\n        assertEquals(\"repo\", cfg.perBotConfiguration(\"xbot\").repositoryName(\"user@host/org/repo:nested/ref\"));\n    }\n\n}\n"
  },
  {
    "path": "bot/src/test/java/org/openjdk/skara/bot/BotRunnerTests.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport org.junit.jupiter.api.*;\nimport org.junit.jupiter.api.condition.EnabledOnOs;\nimport static org.junit.jupiter.api.condition.OS.LINUX;\nimport static org.junit.jupiter.api.condition.OS.MAC;\nimport org.openjdk.skara.json.JSON;\n\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.concurrent.*;\nimport java.util.function.Supplier;\nimport java.util.logging.*;\n\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass TestWorkItem implements WorkItem {\n    private final ConcurrencyCheck concurrencyCheck;\n    private final String description;\n    boolean hasRun = false;\n\n    interface ConcurrencyCheck {\n        boolean concurrentWith(WorkItem other);\n    }\n\n    TestWorkItem(ConcurrencyCheck concurrencyCheck) {\n        this.concurrencyCheck = concurrencyCheck;\n        this.description = null;\n    }\n\n    TestWorkItem(ConcurrencyCheck concurrencyCheck, String description) {\n        this.concurrencyCheck = concurrencyCheck;\n        this.description = description;\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        hasRun = true;\n        System.out.println(\"Item \" + this.toString() + \" now running\");\n        return List.of();\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        return concurrencyCheck.concurrentWith(other);\n    }\n\n    @Override\n    public String toString() {\n        return description != null ? description : super.toString();\n    }\n\n    @Override\n    public String botName() {\n        return \"test-bot\";\n    }\n\n    @Override\n    public String workItemName() {\n        return botName();\n    }\n}\n\nclass TestWorkItemChild extends TestWorkItem {\n    TestWorkItemChild(ConcurrencyCheck concurrencyCheck, String description) {\n        super(concurrencyCheck, description);\n    }\n}\n\nclass TestWorkItemWithFollowup extends TestWorkItem {\n    private List<WorkItem> followUpItems;\n\n    TestWorkItemWithFollowup(ConcurrencyCheck concurrencyCheck, String description, List<WorkItem> followUpItems) {\n        super(concurrencyCheck, description);\n\n        this.followUpItems = followUpItems;\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        hasRun = true;\n        System.out.println(\"Item with followups \" + this.toString() + \" now running\");\n        return followUpItems;\n    }\n}\n\nclass TestBlockedWorkItem implements WorkItem {\n    private final CountDownLatch countDownLatch;\n\n    TestBlockedWorkItem(CountDownLatch countDownLatch) {\n        this.countDownLatch = countDownLatch;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        return false;\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        System.out.println(\"Starting to wait...\");;\n        try {\n            countDownLatch.await();\n        } catch (InterruptedException e) {\n            throw new RuntimeException(e);\n        }\n        System.out.println(\"Done waiting\");\n        return List.of();\n    }\n\n    @Override\n    public String botName() {\n        return \"test-blocked\";\n    }\n\n    @Override\n    public String workItemName() {\n        return botName();\n    }\n}\n\nclass TestBot implements Bot {\n    private final List<WorkItem> items;\n    private final Supplier<List<WorkItem>> itemSupplier;\n\n    TestBot(WorkItem... items) {\n        this.items = Arrays.asList(items);\n        itemSupplier = null;\n    }\n\n    TestBot(Supplier<List<WorkItem>> itemSupplier) {\n        items = null;\n        this.itemSupplier = itemSupplier;\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        if (items != null) {\n            return items;\n        } else {\n            return itemSupplier.get();\n        }\n    }\n\n    @Override\n    public String name() {\n        return \"test-bot\";\n    }\n}\n\nclass BotRunnerTests {\n    @BeforeAll\n    static void setUp() {\n        Logger log = Logger.getGlobal();\n        log.setLevel(Level.FINER);\n        log = Logger.getLogger(\"org.openjdk.bots.cli\");\n        log.setLevel(Level.FINER);\n        ConsoleHandler handler = new ConsoleHandler();\n        handler.setLevel(Level.FINER);\n        log.addHandler(handler);\n    }\n\n    private BotRunnerConfiguration config() {\n        var config = JSON.object();\n        try {\n            return BotRunnerConfiguration.parse(config);\n        } catch (ConfigurationError configurationError) {\n            throw new RuntimeException(configurationError);\n        }\n    }\n\n    private BotRunnerConfiguration config(String json) {\n        var config = JSON.parse(json).asObject();\n        try {\n            return BotRunnerConfiguration.parse(config);\n        } catch (ConfigurationError configurationError) {\n            throw new RuntimeException(configurationError);\n        }\n    }\n    @Test\n    void simpleConcurrent() throws TimeoutException {\n        var item1 = new TestWorkItem(i -> true, \"Item 1\");\n        var item2 = new TestWorkItem(i -> true, \"Item 2\");\n        var bot = new TestBot(item1, item2);\n        var runner = new BotRunner(config(), List.of(bot));\n\n        runner.runOnce(Duration.ofSeconds(10));\n\n        assertTrue(item1.hasRun);\n        assertTrue(item2.hasRun);\n    }\n\n    @Test\n    void simpleSerial() throws TimeoutException {\n        var item1 = new TestWorkItem(i -> false, \"Item 1\");\n        var item2 = new TestWorkItem(i -> false, \"Item 2\");\n        var bot = new TestBot(item1, item2);\n        var runner = new BotRunner(config(), List.of(bot));\n\n        runner.runOnce(Duration.ofSeconds(10));\n\n        assertTrue(item1.hasRun);\n        assertTrue(item2.hasRun);\n    }\n\n    @Test\n    void moreItemsThanScratchPaths() throws TimeoutException {\n        List<TestWorkItem> items = new LinkedList<>();\n        for (int i = 0; i < 20; ++i) {\n            items.add(new TestWorkItem(x -> true, \"Item \" + i));\n        }\n        var bot = new TestBot(items.toArray(new TestWorkItem[0]));\n        var runner = new BotRunner(config(), List.of(bot));\n\n        runner.runOnce(Duration.ofSeconds(10));\n\n        for (var item : items) {\n            assertTrue(item.hasRun);\n        }\n    }\n\n    static class ThrowingItemProvider {\n        private final List<WorkItem> items;\n        private int throwCount;\n\n        ThrowingItemProvider(List<WorkItem> items, int throwCount) {\n            this.items = items;\n            this.throwCount = throwCount;\n        }\n\n        List<WorkItem> get() {\n            if (throwCount-- > 0) {\n                throw new RuntimeException(\"Sorry, can't provide items just yet\");\n            } else {\n                return items;\n            }\n        }\n    }\n\n    @Test\n    void periodItemsThrow() throws TimeoutException {\n        var item1 = new TestWorkItem(i -> false, \"Item 1\");\n        var item2 = new TestWorkItem(i -> false, \"Item 2\");\n        var provider = new ThrowingItemProvider(List.of(item1, item2), 1);\n\n        var bot = new TestBot(provider::get);\n\n        new BotRunner(config(), List.of(bot)).runOnce(Duration.ofSeconds(10));\n        Assertions.assertFalse(item1.hasRun);\n        Assertions.assertFalse(item2.hasRun);\n\n        new BotRunner(config(), List.of(bot)).runOnce(Duration.ofSeconds(10));\n        assertTrue(item1.hasRun);\n        assertTrue(item2.hasRun);\n    }\n\n    @Test\n    void discardAdditionalBlockedItems() throws TimeoutException {\n        var item1 = new TestWorkItem(i -> false, \"Item 1\");\n        var item2 = new TestWorkItem(i -> false, \"Item 2\");\n        var item3 = new TestWorkItem(i -> false, \"Item 3\");\n        var item4 = new TestWorkItem(i -> false, \"Item 4\");\n        var bot = new TestBot(item1, item2, item3, item4);\n\n        var config = config(\"{\\\"runner\\\": { \\\"concurrency\\\": 1 } }\");\n        var runner = new BotRunner(config, List.of(bot));\n\n        runner.runOnce(Duration.ofSeconds(10));\n\n        assertTrue(item1.hasRun);\n        Assertions.assertFalse(item2.hasRun);\n        Assertions.assertFalse(item3.hasRun);\n        assertTrue(item4.hasRun);\n    }\n\n    @Test\n    void dontDiscardDifferentBlockedItems() throws TimeoutException {\n        var item1 = new TestWorkItem(i -> false, \"Item 1\");\n        var item2 = new TestWorkItem(i -> false, \"Item 2\");\n        var item3 = new TestWorkItem(i -> false, \"Item 3\");\n        var item4 = new TestWorkItem(i -> false, \"Item 4\");\n        var item5 = new TestWorkItemChild(i -> false, \"Item 5\");\n        var item6 = new TestWorkItemChild(i -> false, \"Item 6\");\n        var item7 = new TestWorkItemChild(i -> false, \"Item 7\");\n        var bot = new TestBot(item1, item2, item3, item4, item5, item6, item7);\n\n        var config = config(\"{\\\"runner\\\": { \\\"concurrency\\\": 1 } }\");\n        var runner = new BotRunner(config, List.of(bot));\n\n        runner.runOnce(Duration.ofSeconds(10));\n\n        assertTrue(item1.hasRun);\n        Assertions.assertFalse(item2.hasRun);\n        Assertions.assertFalse(item3.hasRun);\n        assertTrue(item4.hasRun);\n        Assertions.assertFalse(item5.hasRun);\n        Assertions.assertFalse(item6.hasRun);\n        assertTrue(item7.hasRun);\n    }\n\n    @Test\n    @EnabledOnOs({LINUX, MAC})\n    void watchdogTrigger() throws TimeoutException {\n        var countdownLatch = new CountDownLatch(1);\n        var item = new TestBlockedWorkItem(countdownLatch);\n        var bot = new TestBot(item);\n        var runner = new BotRunner(config(\"{ \\\"runner\\\": { \\\"watchdog_warn\\\": \\\"PT0.01S\\\", \\\"interval\\\": \\\"PT0.001S\\\" } }\"), List.of(bot));\n\n        var errors = new ArrayList<String>();\n        var log = Logger.getLogger(\"org.openjdk.skara.bot\");\n        log.addHandler(new Handler() {\n            @Override\n            public void publish(LogRecord record) {\n                if (record.getLevel().equals(Level.SEVERE)) {\n                    errors.add(record.getMessage());\n                }\n            }\n\n            @Override\n            public void flush() {\n            }\n\n            @Override\n            public void close() throws SecurityException {\n            }\n        });\n\n        runner.run(Duration.ofMillis(100));\n        assertTrue(errors.size() > 0);\n        assertTrue(errors.size() <= 100);\n        countdownLatch.countDown();\n    }\n\n    @Test\n    void dependentItems() throws TimeoutException {\n        var item2 = new TestWorkItem(i -> true, \"Item 2\");\n        var item3 = new TestWorkItem(i -> true, \"Item 3\");\n\n        var item1 = new TestWorkItemWithFollowup(i -> true, \"Item 1\", List.of(item2, item3));\n        var bot = new TestBot(item1);\n        var runner = new BotRunner(config(), List.of(bot));\n\n        runner.runOnce(Duration.ofSeconds(10));\n\n        assertTrue(item1.hasRun);\n        assertTrue(item2.hasRun);\n        assertTrue(item3.hasRun);\n    }\n}\n"
  },
  {
    "path": "bot/src/test/java/org/openjdk/skara/bot/BotTaskAggregationHandlerTests.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bot;\n\nimport org.junit.jupiter.api.*;\n\nimport java.util.*;\nimport java.util.concurrent.*;\nimport java.util.logging.*;\nimport java.util.stream.*;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.fail;\n\nclass TestBotTaskAggregationHandler extends BotTaskAggregationHandler {\n\n    private final Collection<LogRecord> nonTaskRecords;\n    private final Collection<List<LogRecord>> taskRecords;\n\n    TestBotTaskAggregationHandler() {\n        super(false);\n        nonTaskRecords = new ConcurrentLinkedQueue<>();\n        taskRecords = new ConcurrentLinkedQueue<>();\n    }\n\n    @Override\n    public void publishAggregated(List<LogRecord> task) {\n        taskRecords.add(task);\n    }\n\n    @Override\n    public void publishSingle(LogRecord record) {\n        nonTaskRecords.add(record);\n    }\n\n    Collection<List<LogRecord>> taskRecords() {\n        return taskRecords;\n    }\n\n    Collection<LogRecord> nonTaskRecords() {\n        return nonTaskRecords;\n    }\n}\n\nclass BotTaskAggregationHandlerTests {\n\n    @BeforeAll\n    static void setUp() {\n        Logger log = Logger.getGlobal();\n        log.setLevel(Level.FINEST);\n        ConsoleHandler handler = new ConsoleHandler();\n        handler.setLevel(Level.FINER);\n        log.addHandler(handler);\n    }\n\n    @Test\n    void simpleNonTask() {\n        Logger log = Logger.getGlobal();\n        var handler = new TestBotTaskAggregationHandler();\n        handler.setLevel(Level.FINER);\n        log.addHandler(handler);\n\n        log.fine(\"Not a task log\");\n\n        assertEquals(0, handler.taskRecords().size());\n        assertEquals(1, handler.nonTaskRecords().size());\n    }\n\n    @Test\n    void simpleTask() {\n        Logger log = Logger.getGlobal();\n        var handler = new TestBotTaskAggregationHandler();\n        handler.setLevel(Level.FINER);\n        log.addHandler(handler);\n\n        log.log(Level.FINE, \"Task log start\", BotRunner.TaskPhases.BEGIN);\n        log.log(Level.FINE, \"Task log end\", BotRunner.TaskPhases.END);\n\n        assertEquals(1, handler.taskRecords().size());\n        assertEquals(0, handler.nonTaskRecords().size());\n    }\n\n    static class ConcurrentTask implements Runnable {\n\n        private final CountDownLatch countDownLatch;\n        private final int numLoops;\n\n        ConcurrentTask(CountDownLatch countDownLatch, int numLoops) {\n            this.countDownLatch = countDownLatch;\n            this.numLoops = numLoops;\n        }\n\n        @Override\n        public void run() {\n            try {\n                Logger log = Logger.getGlobal();\n                countDownLatch.await();\n                for (int i = 0; i < numLoops; ++i) {\n                    log.log(Level.FINEST, Long.toString(Thread.currentThread().threadId()), BotRunner.TaskPhases.BEGIN);\n                    log.log(Level.FINEST, Long.toString(Thread.currentThread().threadId()), BotRunner.TaskPhases.END);\n                    log.log(Level.FINEST, Long.toString(Thread.currentThread().threadId()));\n                }\n            } catch (InterruptedException e) {\n                fail(e);\n            }\n        }\n    }\n\n    @Test\n    void concurrentSeparation() {\n        final int concurrency = 50;\n        final int numLoops = 100;\n\n        Logger log = Logger.getGlobal();\n        var handler = new TestBotTaskAggregationHandler();\n        handler.setLevel(Level.FINEST);\n        log.addHandler(handler);\n\n        var countDownLatch = new CountDownLatch(1);\n        var threads = IntStream.range(0, concurrency)\n                .mapToObj(num -> new Thread(new ConcurrentTask(countDownLatch, numLoops)))\n                .collect(Collectors.toList());\n        threads.forEach(Thread::start);\n\n        countDownLatch.countDown();\n        threads.forEach(thread -> {\n            try {\n                thread.join();\n            } catch (InterruptedException e) {\n                fail(e);\n            }\n        });\n\n        assertEquals(concurrency * numLoops, handler.taskRecords().size());\n        assertEquals(concurrency * numLoops, handler.nonTaskRecords().size());\n\n        handler.taskRecords().stream()\n               .flatMap(Collection::stream)\n               .forEach(record -> assertEquals(Long.toString(record.getLongThreadID()), record.getMessage()));\n        handler.nonTaskRecords()\n               .forEach(record -> assertEquals(Long.toString(record.getLongThreadID()), record.getMessage()));\n    }\n\n}\n"
  },
  {
    "path": "bot/src/test/java/org/openjdk/skara/bot/LogContextTests.java",
    "content": "package org.openjdk.skara.bot;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNull;\n\npublic class LogContextTests {\n\n    @Test\n    public void simple() {\n        String key = \"keyname\";\n        assertNull(LogContextMap.get(key), \"Key \" + key + \" already present in context\");\n        try (var __ = new LogContext(key, \"value\")) {\n            assertEquals(\"value\", LogContextMap.get(key), \"Context property not set\");\n        }\n        assertNull(LogContextMap.get(key), \"Context property not removed\");\n    }\n}\n"
  },
  {
    "path": "bots/bridgekeeper/build.gradle",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.bridgekeeper'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.vcs'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.bots.bridgekeeper' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':ci')\n    implementation project(':host')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':bot')\n    implementation project(':census')\n    implementation project(':json')\n    implementation project(':vcs')\n    implementation project(':metrics')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "bots/bridgekeeper/src/main/java/module-info.java",
    "content": "import org.openjdk.skara.bots.bridgekeeper.BridgekeeperBotFactory;\n\n/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.bots.bridgekeeper {\n    requires org.openjdk.skara.bot;\n    requires java.logging;\n\n    provides org.openjdk.skara.bot.BotFactory with BridgekeeperBotFactory;\n}\n"
  },
  {
    "path": "bots/bridgekeeper/src/main/java/org/openjdk/skara/bots/bridgekeeper/BridgekeeperBotFactory.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.bridgekeeper;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.json.JSONValue;\n\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\npublic class BridgekeeperBotFactory implements BotFactory {\n    static final String NAME = \"bridgekeeper\";\n    @Override\n    public String name() {\n        return NAME;\n    }\n\n    @Override\n    public List<Bot> create(BotConfiguration configuration) {\n        var ret = new ArrayList<Bot>();\n        var specific = configuration.specific();\n\n        for (var repo : specific.get(\"mirrors\").asArray()) {\n            var bot = new PullRequestCloserBot(configuration.repository(repo.asString()), PullRequestCloserBot.Type.MIRROR);\n            ret.add(bot);\n        }\n        for (var repo : specific.get(\"data\").asArray()) {\n            var bot = new PullRequestCloserBot(configuration.repository(repo.asString()), PullRequestCloserBot.Type.DATA);\n            ret.add(bot);\n        }\n        var pruned = new HashMap<HostedRepository, Duration>();\n        var ignoredUsers = specific.get(\"pruned\").get(\"ignored\").get(\"users\").stream()\n                .map(JSONValue::asString)\n                .collect(Collectors.toSet());\n        for (var repo : specific.get(\"pruned\").get(\"repositories\").fields()) {\n            var maxAge = Duration.parse(repo.value().get(\"maxage\").asString());\n            pruned.put(configuration.repository(repo.name()), maxAge);\n        }\n        if (!pruned.isEmpty()) {\n            var bot = new PullRequestPrunerBot(pruned, ignoredUsers);\n            ret.add(bot);\n        }\n        return ret;\n    }\n}\n"
  },
  {
    "path": "bots/bridgekeeper/src/main/java/org/openjdk/skara/bots/bridgekeeper/PullRequestCloserBot.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.bridgekeeper;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.forge.*;\n\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.function.Consumer;\nimport java.util.logging.Logger;\n\nclass PullRequestCloserBotWorkItem implements WorkItem {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n    private final HostedRepository repository;\n    private final PullRequest pr;\n    private final Consumer<RuntimeException> errorHandler;\n    private final PullRequestCloserBot.Type type;\n\n    PullRequestCloserBotWorkItem(HostedRepository repository, PullRequest pr, PullRequestCloserBot.Type type, Consumer<RuntimeException> errorHandler) {\n        this.pr = pr;\n        this.repository = repository;\n        this.type = type;\n        this.errorHandler = errorHandler;\n    }\n\n    private static final String WELCOME_MARKER = \"<!-- PullrequestCloserBot welcome message -->\";\n\n    private void checkWelcomeMessage() {\n        log.info(\"Checking welcome message of \" + pr);\n\n        var comments = pr.comments();\n        var welcomePosted = comments.stream()\n                                    .anyMatch(comment -> comment.body().contains(WELCOME_MARKER));\n\n        if (!welcomePosted) {\n            String message = null;\n            if (type == PullRequestCloserBot.Type.MIRROR) {\n                message = \"Welcome to the OpenJDK organization on GitHub!\\n\\n\" +\n                \"This repository is currently a read-only git mirror of the official Mercurial \" +\n                \"repository (located at https://hg.openjdk.org/). As such, we are not \" +\n                \"currently accepting pull requests here. If you would like to contribute to \" +\n                \"the OpenJDK project, please see https://openjdk.org/contribute/ on how \" +\n                \"to proceed.\\n\\n\" +\n                \"This pull request will be automatically closed.\";\n            } else if (type == PullRequestCloserBot.Type.DATA) {\n                message = \"Welcome to the OpenJDK organization on GitHub!\\n\\n\" +\n                \"This repository currently holds only automatically generated data and therefore does not accept pull requests.\" +\n                \"This pull request will be automatically closed.\";\n            } else {\n                message = \"Welcome to the OpenJDK organization on GitHub!\\n\\n\" +\n                \"This repository does not currently accept pull requests.\" +\n                \"This pull request will be automatically closed.\";\n            }\n\n            log.fine(\"Posting welcome message\");\n            pr.addComment(WELCOME_MARKER + \"\\n\\n\" + message);\n        }\n        pr.setState(PullRequest.State.CLOSED);\n    }\n\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof PullRequestCloserBotWorkItem otherItem)) {\n            return true;\n        }\n        if (!pr.isSame(otherItem.pr)) {\n            return true;\n        }\n        return false;\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        checkWelcomeMessage();\n        return List.of();\n    }\n\n    @Override\n    public void handleRuntimeException(RuntimeException e) {\n        errorHandler.accept(e);\n    }\n\n    @Override\n    public String toString() {\n        return \"PullRequestCloserBotWorkItem@\" + repository.name() + \"#\" + pr.id();\n    }\n\n    @Override\n    public String botName() {\n        return BridgekeeperBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return \"closer\";\n    }\n}\n\npublic class PullRequestCloserBot implements Bot {\n    private final HostedRepository remoteRepo;\n    private final PullRequestUpdateCache updateCache;\n    public enum Type {\n        MIRROR,\n        DATA\n    }\n    private final Type type;\n\n    PullRequestCloserBot(HostedRepository repo, Type type) {\n        this.remoteRepo = repo;\n        this.updateCache = new PullRequestUpdateCache();\n        this.type = type;\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        List<WorkItem> ret = new LinkedList<>();\n\n        for (var pr : remoteRepo.openPullRequests()) {\n            if (updateCache.needsUpdate(pr)) {\n                var item = new PullRequestCloserBotWorkItem(remoteRepo, pr, type, e -> updateCache.invalidate(pr));\n                ret.add(item);\n            }\n        }\n\n        return ret;\n    }\n\n    @Override\n    public String name() {\n        return BridgekeeperBotFactory.NAME;\n    }\n\n    @Override\n    public String toString() {\n        return \"PullRequestCloserBot@\" + remoteRepo.name();\n    }\n\n    public Type getType() {\n        return type;\n    }\n}\n"
  },
  {
    "path": "bots/bridgekeeper/src/main/java/org/openjdk/skara/bots/bridgekeeper/PullRequestPrunerBot.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.bridgekeeper;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.nio.file.Path;\nimport java.time.*;\nimport java.util.*;\nimport java.util.function.Function;\nimport java.util.logging.Logger;\nimport java.util.stream.*;\n\nclass PullRequestPrunerBotWorkItem implements WorkItem {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n    private final PullRequest pr;\n    private final Duration maxAge;\n    private final Set<String> ignoredUsers;\n\n    PullRequestPrunerBotWorkItem(PullRequest pr, Duration maxAge, Set<String> ignoredUsers) {\n        this.pr = pr;\n        this.maxAge = maxAge;\n        this.ignoredUsers = ignoredUsers;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof PullRequestPrunerBotWorkItem otherItem)) {\n            return true;\n        }\n        if (!pr.isSame(otherItem.pr)) {\n            return true;\n        }\n        return false;\n    }\n\n    // Prune durations are on the order of days and weeks\n    private String formatDuration(Duration duration) {\n        var count = duration.toDays();\n        var unit = \"day\";\n\n        if (count > 14) {\n            count /= 7;\n            unit = \"week\";\n        }\n        if (count != 1) {\n            unit += \"s\";\n        }\n        return count + \" \" + unit;\n    }\n\n    private static final String NOTICE_MARKER = \"<!-- PullrequestCloserBot auto close notification -->\";\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        var comments = pr.comments();\n        if (comments.size() > 0) {\n            var lastComment = comments.stream()\n                    .filter(comment -> !ignoredUsers.contains(comment.author().username()))\n                    .toList()\n                    .getLast();\n            if (lastComment.author().equals(pr.repository().forge().currentUser()) && lastComment.body().contains(NOTICE_MARKER)\n                    && !lastComment.createdAt().isBefore(pr.lastTouchedTime())) {\n                var message = \"@\" + pr.author().username() + \" This pull request has been inactive for more than \" +\n                        formatDuration(maxAge.multipliedBy(2)) + \" and will now be automatically closed. If you would \" +\n                        \"like to continue working on this pull request in the future, feel free to reopen it! This can be done \" +\n                        \"using the `/open` pull request command.\";\n                log.fine(\"Posting prune message\");\n                pr.addComment(message);\n                pr.setState(PullRequest.State.CLOSED);\n                return List.of();\n            }\n        }\n\n        var message = \"@\" + pr.author().username() + \" This pull request has been inactive for more than \" +\n                formatDuration(maxAge) + \" and will be automatically closed if another \" + formatDuration(maxAge) +\n                \" passes without any activity. To avoid this, simply issue a `/touch` or `/keepalive` command to the pull request. Feel free \" +\n                \"to ask for assistance if you need help with progressing this pull request towards integration!\";\n\n        log.fine(\"Posting prune notification message\");\n        pr.addComment(NOTICE_MARKER + \"\\n\\n\" + message);\n        return List.of();\n    }\n\n    @Override\n    public String toString() {\n        return \"PullRequestPrunerBotWorkItem@\" + pr.repository().name() + \"#\" + pr.id();\n    }\n\n    @Override\n    public String botName() {\n        return BridgekeeperBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return \"pruner\";\n    }\n}\n\npublic class PullRequestPrunerBot implements Bot {\n    private final Map<HostedRepository, Duration> maxAges;\n    private final Deque<HostedRepository> repositoriesToCheck = new LinkedList<>();\n    private final Deque<PullRequest> pullRequestToCheck = new LinkedList<>();\n\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.bridgekeeper\");\n\n    private Duration currentMaxAge;\n    private Set<String> ignoredUsers;\n\n    PullRequestPrunerBot(Map<HostedRepository, Duration> maxAges, Set<String> ignoredUsers) {\n        this.maxAges = maxAges;\n        this.ignoredUsers = ignoredUsers;\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        List<WorkItem> ret = new LinkedList<>();\n\n        if (repositoriesToCheck.isEmpty()) {\n            repositoriesToCheck.addAll(maxAges.keySet());\n        }\n        if (pullRequestToCheck.isEmpty()) {\n            var nextRepository = repositoriesToCheck.pollFirst();\n            if (nextRepository == null) {\n                log.warning(\"No repositories configured for pruning\");\n                return ret;\n            }\n            currentMaxAge = maxAges.get(nextRepository);\n            pullRequestToCheck.addAll(nextRepository.openPullRequests());\n        }\n\n        var pr = pullRequestToCheck.pollFirst();\n        if (pr == null) {\n            log.info(\"No prune candidates found - skipping\");\n            return ret;\n        }\n\n        // Latest prune-delaying action (deliberately excluding pr.updatedAt, as it can be updated spuriously)\n        var latestAction = Stream.of(Stream.of(pr.createdAt(), pr.lastTouchedTime()),\n                                   pr.comments().stream()\n                                     .filter(comment -> !ignoredUsers.contains(comment.author().username()))\n                                     .map(Comment::updatedAt),\n                                   pr.reviews().stream()\n                                     .map(Review::createdAt),\n                                   pr.reviewCommentsAsComments().stream()\n                                     .map(Comment::updatedAt))\n                               .flatMap(Function.identity())\n                               .max(ZonedDateTime::compareTo).orElseThrow();\n\n        var actualMaxAge = pr.isDraft() ? currentMaxAge.multipliedBy(2) : currentMaxAge;\n        var oldestAllowed = ZonedDateTime.now().minus(actualMaxAge);\n        if (latestAction.isBefore(oldestAllowed)) {\n            var item = new PullRequestPrunerBotWorkItem(pr, actualMaxAge, ignoredUsers);\n            ret.add(item);\n        }\n\n        return ret;\n    }\n\n    @Override\n    public String name() {\n        return BridgekeeperBotFactory.NAME;\n    }\n\n    @Override\n    public String toString() {\n        return \"PullRequestPrunerBot\";\n    }\n\n    public Map<HostedRepository, Duration> getMaxAges() {\n        return maxAges;\n    }\n\n    public Set<String> getIgnoredUsers() {\n        return ignoredUsers;\n    }\n}\n"
  },
  {
    "path": "bots/bridgekeeper/src/test/java/org/openjdk/skara/bots/bridgekeeper/BridgekeeperBotFactoryTest.java",
    "content": "/*\n * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.bridgekeeper;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.json.JWCC;\nimport org.openjdk.skara.test.TestBotFactory;\nimport org.openjdk.skara.test.TestHostedRepository;\n\nimport java.time.Duration;\nimport java.util.Set;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\npublic class BridgekeeperBotFactoryTest {\n    @Test\n    public void testCreate() {\n        String jsonString = \"\"\"\n                {\n                  \"mirrors\": [\n                    \"mirror1\",\n                    \"mirror2\",\n                    \"mirror3\"\n                  ],\n                  \"data\": [\n                    \"data1\",\n                    \"data2\",\n                    \"data3\"\n                  ],\n                  \"pruned\": {\n                    \"ignored\": {\n                        \"users\": [\n                            \"user1\",\n                            \"user2\"\n                        ]\n                    },\n                    \"repositories\": {\n                        \"pruned1\": {\n                          \"maxage\": \"P1D\"\n                        },\n                        \"pruned2\": {\n                          \"maxage\": \"PT48H\"\n                        },\n                        \"pruned3\": {\n                          \"maxage\": \"PT4320M\"\n                        }\n                    }\n                  }\n                }\n                \"\"\";\n        var jsonConfig = JWCC.parse(jsonString).asObject();\n\n        var pruned1 = new TestHostedRepository(\"pruned1\");\n        var pruned2 = new TestHostedRepository(\"pruned2\");\n        var pruned3 = new TestHostedRepository(\"pruned3\");\n        var testBotFactory = TestBotFactory.newBuilder()\n                .addHostedRepository(\"mirror1\", new TestHostedRepository(\"mirror1\"))\n                .addHostedRepository(\"mirror2\", new TestHostedRepository(\"mirror2\"))\n                .addHostedRepository(\"mirror3\", new TestHostedRepository(\"mirror3\"))\n                .addHostedRepository(\"data1\", new TestHostedRepository(\"data1\"))\n                .addHostedRepository(\"data2\", new TestHostedRepository(\"data2\"))\n                .addHostedRepository(\"data3\", new TestHostedRepository(\"data3\"))\n                .addHostedRepository(\"pruned1\", pruned1)\n                .addHostedRepository(\"pruned2\", pruned2)\n                .addHostedRepository(\"pruned3\", pruned3)\n                .build();\n\n        var bots = testBotFactory.createBots(BridgekeeperBotFactory.NAME, jsonConfig);\n        assertEquals(7, bots.size());\n\n        var mirrorPullRequestCloserBots = bots.stream()\n                .filter(e -> e.getClass().equals(PullRequestCloserBot.class))\n                .filter(e -> ((PullRequestCloserBot) e).getType().equals(PullRequestCloserBot.Type.MIRROR))\n                .toList();\n        var dataPullRequestCloserBots = bots.stream()\n                .filter(e -> e.getClass().equals(PullRequestCloserBot.class))\n                .filter(e -> ((PullRequestCloserBot) e).getType().equals(PullRequestCloserBot.Type.DATA))\n                .toList();\n        var pullRequestPrunerBots = bots.stream()\n                .filter(e -> e.getClass().equals(PullRequestPrunerBot.class))\n                .toList();\n\n        // A mirror pullRequestCloserBot for every configured mirror repository\n        assertEquals(3, mirrorPullRequestCloserBots.size());\n        // A data pullRequestCloserBot for every configured data repository\n        assertEquals(3, dataPullRequestCloserBots.size());\n        // One pullRequestPrunerBot for all configured pruned repository\n        assertEquals(1, pullRequestPrunerBots.size());\n\n        // Check whether each bot is combined with the correct repo\n        assertEquals(\"PullRequestCloserBot@mirror1\", mirrorPullRequestCloserBots.get(0).toString());\n        assertEquals(\"PullRequestCloserBot@mirror2\", mirrorPullRequestCloserBots.get(1).toString());\n        assertEquals(\"PullRequestCloserBot@mirror3\", mirrorPullRequestCloserBots.get(2).toString());\n        assertEquals(\"PullRequestCloserBot@data1\", dataPullRequestCloserBots.get(0).toString());\n        assertEquals(\"PullRequestCloserBot@data2\", dataPullRequestCloserBots.get(1).toString());\n        assertEquals(\"PullRequestCloserBot@data3\", dataPullRequestCloserBots.get(2).toString());\n\n        var pullRequestPrunerBot = (PullRequestPrunerBot) pullRequestPrunerBots.get(0);\n        assertEquals(\"PullRequestPrunerBot\", pullRequestPrunerBot.toString());\n        var maxAges = pullRequestPrunerBot.getMaxAges();\n        assertEquals(Duration.ofDays(1), maxAges.get(pruned1));\n        assertEquals(Duration.ofDays(2), maxAges.get(pruned2));\n        assertEquals(Duration.ofDays(3), maxAges.get(pruned3));\n        assertEquals(Set.of(\"user1\", \"user2\"), pullRequestPrunerBot.getIgnoredUsers());\n    }\n}\n"
  },
  {
    "path": "bots/bridgekeeper/src/test/java/org/openjdk/skara/bots/bridgekeeper/PullRequestCloserBotTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.bridgekeeper;\n\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.test.*;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.issuetracker.Issue.State.*;\n\nclass PullRequestCloserBotTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var bot = new PullRequestCloserBot(author, PullRequestCloserBot.Type.MIRROR);\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n            assertEquals(OPEN, pr.state());\n\n            // Let the bot see it\n            TestBotRunner.runPeriodicItems(bot);\n\n            // There should now be no open PRs\n            var prs = author.openPullRequests();\n            assertEquals(0, prs.size());\n\n            var updatedPr = author.pullRequest(pr.id());\n            assertEquals(CLOSED, updatedPr.state());\n        }\n    }\n\n    @Test\n    void keepClosing(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var bot = new PullRequestCloserBot(author, PullRequestCloserBot.Type.MIRROR);\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Let the bot see it\n            TestBotRunner.runPeriodicItems(bot);\n\n            // There should now be no open PRs\n            var prs = author.openPullRequests();\n            assertEquals(0, prs.size());\n\n            // The author is persistent\n            pr.setState(Issue.State.OPEN);\n            prs = author.openPullRequests();\n            assertEquals(1, prs.size());\n\n            // But so is the bot\n            TestBotRunner.runPeriodicItems(bot);\n            prs = author.openPullRequests();\n            assertEquals(0, prs.size());\n\n            // There should still only be one welcome comment\n            assertEquals(1, pr.comments().size());\n\n            // The message should mention mirroring\n            assertTrue(pr.comments().get(0).body().contains(\"This repository is currently a read-only git mirror\"));\n        }\n    }\n\n    @Test\n    void dataMessage(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var bot = new PullRequestCloserBot(author, PullRequestCloserBot.Type.DATA);\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Let the bot see it\n            TestBotRunner.runPeriodicItems(bot);\n\n            // There should now be no open PRs\n            var prs = author.openPullRequests();\n            assertEquals(0, prs.size());\n\n            // The author is persistent\n            pr.setState(Issue.State.OPEN);\n            prs = author.openPullRequests();\n            assertEquals(1, prs.size());\n\n            // But so is the bot\n            TestBotRunner.runPeriodicItems(bot);\n            prs = author.openPullRequests();\n            assertEquals(0, prs.size());\n\n            // There should still only be one welcome comment\n            assertEquals(1, pr.comments().size());\n\n            // The message should mention automatically generated data\n            assertTrue(pr.comments().get(0).body().contains(\"This repository currently holds only automatically generated data\"));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/bridgekeeper/src/test/java/org/openjdk/skara/bots/bridgekeeper/PullRequestPrunerBotTests.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.bridgekeeper;\n\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.test.*;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.util.Map;\nimport java.util.Set;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass PullRequestPrunerBotTests {\n    @Test\n    void close(TestInfo testInfo) throws IOException, InterruptedException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var ignoredUser = credentials.getHostedRepository();\n            var bot = new PullRequestPrunerBot(Map.of(author, Duration.ofMillis(1)), Set.of(\"user2\"));\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n            var ignoredUserPr = ignoredUser.pullRequest(pr.id());\n            // Make sure the timeout expires\n            Thread.sleep(100);\n\n            // Let the bot see it - it should give a notice\n            TestBotRunner.runPeriodicItems(bot);\n\n            assertEquals(1, pr.comments().size());\n            assertTrue(pr.comments().get(0).body().contains(\"will be automatically closed if\"));\n\n            pr.addComment(\"I'm still working on it!\");\n\n            // Make sure the timeout expires again\n            Thread.sleep(100);\n\n            // Let the bot see it - it should post a second notice\n            TestBotRunner.runPeriodicItems(bot);\n\n            assertEquals(3, pr.comments().size());\n            assertTrue(pr.comments().get(2).body().contains(\"will be automatically closed if\"));\n\n            // Add a commit to the pr\n            var editHash2 = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash2, author.authenticatedUrl(), \"edit\", true);\n            TestBotRunner.runPeriodicItems(bot);\n            // Make sure the timeout expires again\n            Thread.sleep(100);\n            TestBotRunner.runPeriodicItems(bot);\n            assertEquals(Issue.State.OPEN, pr.store().state());\n            assertEquals(4, pr.comments().size());\n            assertTrue(pr.comments().get(3).body().contains(\"will be automatically closed if\"));\n\n\n            pr.makeDraft();\n            // Make sure the timeout expires again\n            Thread.sleep(100);\n            TestBotRunner.runPeriodicItems(bot);\n            assertEquals(Issue.State.OPEN, pr.store().state());\n            assertEquals(5, pr.comments().size());\n            assertTrue(pr.comments().get(4).body().contains(\"will be automatically closed if\"));\n\n\n            pr.makeNotDraft();\n            // Make sure the timeout expires again\n            Thread.sleep(100);\n            TestBotRunner.runPeriodicItems(bot);\n            assertEquals(Issue.State.OPEN, pr.store().state());\n            assertEquals(6, pr.comments().size());\n            assertTrue(pr.comments().get(5).body().contains(\"will be automatically closed if\"));\n\n            // Post a comment as ignored User\n            ignoredUserPr.addComment(\"It should be ignored\");\n            // Make sure the timeout expires again\n            Thread.sleep(100);\n            // The bot should now close it\n            TestBotRunner.runPeriodicItems(bot);\n\n            // There should now be no open PRs\n            var prs = author.openPullRequests();\n            assertEquals(0, prs.size());\n\n            // There should be a mention on how to reopen\n            var comment = pr.comments().getLast().body();\n            assertTrue(comment.contains(\"`/open`\"), comment);\n        }\n    }\n\n    @Test\n    void dontClose(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var bot = new PullRequestPrunerBot(Map.of(author, Duration.ofDays(3)), Set.of());\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Let the bot see it\n            TestBotRunner.runPeriodicItems(bot);\n\n            // There should still be an open PR\n            var prs = author.openPullRequests();\n            assertEquals(1, prs.size());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/censussync/build.gradle",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.censussync'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.bots.censussync' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':bot')\n    implementation project(':ci')\n    implementation project(':vcs')\n    implementation project(':host')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':census')\n    implementation project(':process')\n    implementation project(':json')\n    implementation project(':network')\n    implementation project(':storage')\n    implementation project(':xml')\n    implementation project(':metrics')\n    implementation project(':bots:common')\n    implementation project(':jbs')\n    implementation project(':jcheck')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "bots/censussync/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.bots.censussync {\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.host;\n    requires org.openjdk.skara.network;\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.process;\n    requires org.openjdk.skara.storage;\n    requires org.openjdk.skara.xml;\n    requires java.logging;\n    requires java.xml;\n    requires java.net.http;\n    requires org.openjdk.skara.jcheck;\n    requires org.openjdk.skara.jbs;\n    requires org.openjdk.skara.bots.common;\n\n    provides org.openjdk.skara.bot.BotFactory with org.openjdk.skara.bots.censussync.CensusSyncBotFactory;\n}\n"
  },
  {
    "path": "bots/censussync/src/main/java/org/openjdk/skara/bots/censussync/CensusSyncBotFactory.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.censussync;\n\nimport org.openjdk.skara.bot.*;\n\nimport java.net.URI;\nimport java.util.*;\nimport java.util.logging.Logger;\n\npublic class CensusSyncBotFactory implements BotFactory {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");\n\n    static final String NAME = \"censussync\";\n    @Override\n    public String name() {\n        return NAME;\n    }\n\n    @Override\n    public List<Bot> create(BotConfiguration configuration) {\n        var bots = new ArrayList<Bot>();\n        var specific = configuration.specific();\n        for (var sync : specific.get(\"sync\").asArray()) {\n            switch (sync.get(\"method\").asString()) {\n                case \"unify\" -> {\n                    var from = configuration.repository(sync.get(\"from\").asString());\n                    var to = configuration.repository(sync.get(\"to\").asString());\n                    var version = sync.get(\"version\").asInt();\n                    bots.add(new CensusSyncUnifyBot(from, to, version));\n                }\n                case \"split\" -> {\n                    var from = URI.create(sync.get(\"from\").asString());\n                    var to = configuration.repository(sync.get(\"to\").asString());\n                    var version = sync.get(\"version\").asInt();\n                    bots.add(new CensusSyncSplitBot(from, to, version));\n                }\n            }\n        }\n        return bots;\n    }\n}\n"
  },
  {
    "path": "bots/censussync/src/main/java/org/openjdk/skara/bots/censussync/CensusSyncSplitBot.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\npackage org.openjdk.skara.bots.censussync;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.bots.common.BotUtils;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.network.RestRequest;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.xml.XML;\nimport org.w3c.dom.Element;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.nio.file.*;\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.logging.Logger;\n\npublic class CensusSyncSplitBot implements Bot, WorkItem {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n    private final URI from;\n    private final HostedRepository to;\n    private final int version;\n    private final RestRequest request;\n\n    private String lastCensus = \"\";\n\n    CensusSyncSplitBot(URI from, HostedRepository to, int version) {\n        this.from = from;\n        this.to = to;\n        this.version = version;\n\n        request = new RestRequest(from);\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof CensusSyncSplitBot o)) {\n            return true;\n        }\n        return !o.to.equals(to);\n    }\n\n    @Override\n    public String toString() {\n        return \"CensusSyncSplitBot(\" + from + \"->\" + to.name() + \"@\" + version + \")\";\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        return List.of(this);\n    }\n\n    private static PrintWriter newPrintWriter(Path p) throws IOException {\n        return new PrintWriter(Files.newBufferedWriter(p));\n    }\n\n    private static List<Path> syncVersion(Element census, Path to) throws IOException {\n        var date = ZonedDateTime.parse(XML.attribute(census, \"time\"));\n        var timestamp = date.toInstant();\n        var filename = to.resolve(\"version.xml\");\n        try (var file = newPrintWriter(filename)) {\n            file.println(\"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\");\n            file.format(\"<version format=\\\"1\\\" timestamp=\\\"%s\\\" />%n\", timestamp.toString());\n        }\n        return List.of(filename);\n    }\n\n    private static List<Path> syncContributors(Element census, Path to) throws IOException {\n        var filename = to.resolve(\"contributors.xml\");\n        try (var file = newPrintWriter(filename)) {\n            file.println(\"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\");\n            file.println(\"<contributors>\");\n            for (var person : XML.children(census, \"person\")) {\n                var username = XML.attribute(person, \"name\");\n                var fullName = XML.child(person, \"full-name\").getTextContent();\n                file.format(\"    <contributor username=\\\"%s\\\" full-name=\\\"%s\\\" />%n\",\n                            username, fullName);\n            }\n            file.println(\"</contributors>\");\n        }\n        return List.of(filename);\n    }\n\n    private static List<Path> syncGroups(Element census, Path to) throws IOException {\n        var dir = to.resolve(\"groups\");\n        var ret = new ArrayList<Path>();\n        for (var group : XML.children(census, \"group\")) {\n            Files.createDirectories(dir);\n\n            String lead = null;\n            var members = new ArrayList<String>();\n            for (var person : XML.children(group, \"person\")) {\n                if (XML.hasAttribute(person, \"role\")) {\n                    var role = XML.attribute(person, \"role\");\n                    if (!role.equals(\"lead\")) {\n                        throw new IOException(\"Unexpected role: \" + role);\n                    }\n                    lead = XML.attribute(person, \"ref\");\n                } else {\n                    members.add(XML.attribute(person, \"ref\"));\n                }\n            }\n\n            var name = XML.attribute(group, \"name\");\n            var fullName = XML.child(group, \"full-name\").getTextContent();\n            var filename = dir.resolve(name + \".xml\");\n            try (var file = newPrintWriter(filename)) {\n                file.format(\"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>%n\");\n                file.format(\"<group name=\\\"%s\\\" full-name=\\\"%s\\\">%n\", name, BotUtils.escape(fullName));\n                file.format(\"    <lead username=\\\"%s\\\" />%n\", lead);\n                for (var member : members) {\n                    file.format(\"    <member username=\\\"%s\\\" />%n\", member);\n                }\n\n                file.format(\"</group>%n\");\n            }\n            ret.add(filename);\n        }\n        return ret;\n    }\n\n    private static List<Path> syncProjects(Element census, Path to) throws IOException {\n        var dir = to.resolve(\"projects\");\n        var ret = new ArrayList<Path>();\n        for (var project : XML.children(census, \"project\")) {\n            Files.createDirectories(dir);\n\n            String lead = null;\n            var committers = new ArrayList<String>();\n            var reviewers = new ArrayList<String>();\n            var authors = new ArrayList<String>();\n\n            var name = XML.attribute(project, \"name\");\n\n            for (var person : XML.children(project, \"person\")) {\n                var role = XML.attribute(person, \"role\");\n                var username = XML.attribute(person, \"ref\");\n                switch (role) {\n                    case \"lead\":\n                        lead = username;\n                        break;\n                    case \"reviewer\":\n                        reviewers.add(username);\n                        break;\n                    case \"committer\":\n                        committers.add(username);\n                        break;\n                    case \"author\":\n                        authors.add(username);\n                        break;\n                    default:\n                        if (name.equals(\"openjfx\") && (username.equals(\"dwookey\") || username.equals(\"jpereda\"))) {\n                            authors.add(username);\n                        } else {\n                            throw new IOException(\"Unexpected role '\" + role +\n                                                          \"' for user '\" + username +\n                                                          \"' in project '\" + name + \"'\");\n                        }\n                }\n            }\n\n            var fullName = XML.child(project, \"full-name\").getTextContent();\n            var sponsor = XML.attribute(XML.child(project, \"sponsor\"), \"ref\");\n            var filename = dir.resolve(name + \".xml\");\n            try (var file = newPrintWriter(filename)) {\n                file.format(\"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>%n\");\n                file.format(\"<project name=\\\"%s\\\" full-name=\\\"%s\\\" sponsor=\\\"%s\\\">%n\", name, BotUtils.escape(fullName), sponsor);\n                file.format(\"    <lead username=\\\"%s\\\" since=\\\"0\\\" />%n\", lead);\n\n                for (var reviewer : reviewers) {\n                    file.format(\"    <reviewer username=\\\"%s\\\" since=\\\"0\\\" />%n\", reviewer);\n                }\n                for (var committer : committers) {\n                    file.format(\"    <committer username=\\\"%s\\\" since=\\\"0\\\" />%n\", committer);\n                }\n                for (var author : authors) {\n                    file.format(\"    <author username=\\\"%s\\\" since=\\\"0\\\" />%n\", author);\n                }\n\n                file.format(\"</project>%n\");\n            }\n            ret.add(filename);\n        }\n        return ret;\n    }\n\n    private static List<Path> sync(String from, Path to) throws IOException {\n        var document = XML.parse(from);\n        var census = XML.child(document, \"census\");\n        var ret = new ArrayList<Path>();\n\n        ret.addAll(syncVersion(census, to));\n        ret.addAll(syncContributors(census, to));\n        ret.addAll(syncGroups(census, to));\n        ret.addAll(syncProjects(census, to));\n\n        return ret;\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratch) {\n        try {\n            var currentCensus = request.get().executeUnparsed();\n            if (currentCensus.equals(lastCensus)) {\n                log.fine(\"No census changes detected\");\n                return List.of();\n            }\n\n            var toDir = scratch.resolve(\"to.git\");\n            var toRepo = Repository.materialize(toDir, to.authenticatedUrl(), Branch.defaultFor(VCS.GIT).name());\n\n            var updatedFiles = sync(currentCensus, toDir);\n            if (!toRepo.isClean()) {\n                toRepo.add(updatedFiles);\n                var head = toRepo.commit(\"Updated census\", \"duke\", \"duke@openjdk.org\");\n                toRepo.push(head, to.authenticatedUrl(), Branch.defaultFor(VCS.GIT).name(), false);\n            } else {\n                log.info(\"New census data did not result in any changes\");\n            }\n\n            lastCensus = currentCensus;\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        return List.of();\n    }\n\n    @Override\n    public String name() {\n        return CensusSyncBotFactory.NAME;\n    }\n\n    @Override\n    public String botName() {\n        return name();\n    }\n\n    @Override\n    public String workItemName() {\n        return \"split\";\n    }\n}\n"
  },
  {
    "path": "bots/censussync/src/main/java/org/openjdk/skara/bots/censussync/CensusSyncUnifyBot.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\npackage org.openjdk.skara.bots.censussync;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.bots.common.BotUtils;\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.util.*;\nimport java.nio.file.*;\nimport java.util.logging.Logger;\nimport java.time.*;\nimport java.time.format.*;\n\npublic class CensusSyncUnifyBot implements Bot, WorkItem {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n    private final HostedRepository from;\n    private final HostedRepository to;\n    private final int version;\n    private Hash last;\n\n    CensusSyncUnifyBot(HostedRepository from, HostedRepository to, int version) {\n        this.from = from;\n        this.to = to;\n        this.version = version;\n        this.last = null;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof CensusSyncUnifyBot o)) {\n            return true;\n        }\n        return !o.to.equals(to);\n    }\n\n    @Override\n    public String toString() {\n        return \"CensusSyncUnifyBot(\" + from.name() + \"->\" + to.name() + \"@\" + version + \")\";\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        return List.of(this);\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratch) {\n        try {\n            var fromDir = scratch.resolve(\"from.git\");\n            var fromRepo = Repository.materialize(fromDir, from.authenticatedUrl(), Branch.defaultFor(VCS.GIT).name());\n            if (last != null && last.equals(fromRepo.head())) {\n                // Nothing to do\n                return List.of();\n            }\n\n            var census = Census.parse(fromDir);\n\n            var toDir = scratch.resolve(\"to.git\");\n            var toRepo = Repository.materialize(toDir, to.authenticatedUrl(), Branch.defaultFor(VCS.GIT).name());\n\n            var censusXML = toRepo.root().resolve(\"census.xml\");\n            if (!Files.exists(censusXML)) {\n                Files.createFile(censusXML);\n            }\n            try (var file = new PrintWriter(Files.newBufferedWriter(censusXML))) {\n                file.println(\"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\");\n                var formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;\n                file.println(\"<census time=\\\"\" + ZonedDateTime.now().format(formatter) + \"\\\">\");\n                for (var contributor : census.contributors()) {\n                    file.println(\"<person name=\\\"\" + contributor.username() + \"\\\">\");\n                    file.println(\"  <full-name>\" + contributor.fullName().orElse(\"\") + \"</full-name>\");\n                    file.println(\"</person>\");\n                }\n                for (var group : census.groups()) {\n                    file.println(\"<group name=\\\"\" + group.name() + \"\\\">\");\n                    file.println(\"  <full-name>\" + BotUtils.escape(group.fullName()) + \"</full-name>\");\n                    file.println(\"  <person ref=\\\"\" + group.lead().username() + \"\\\" role=\\\"lead\\\" />\");\n                    for (var member : group.members()) {\n                        if (!member.username().equals(group.lead().username())) {\n                            file.println(\"  <person ref=\\\"\" + member.username() + \"\\\" />\");\n                        }\n                    }\n                    file.println(\"</group>\");\n                }\n                for (var project : census.projects()) {\n                    file.println(\"<project name=\\\"\" + project.name() + \"\\\">\");\n                    file.println(\"  <full-name>\" + BotUtils.escape(project.fullName()) + \"</full-name>\");\n                    file.println(\"  <sponsor ref=\\\"\" + project.sponsor().name() + \"\\\" />\");\n\n                    var roles = project.roles(version);\n                    for (var role : roles.keySet()) {\n                        for (var member : roles.get(role)) {\n                            file.println(\"  <person role=\\\"\" + role + \"\\\" ref=\\\"\" + member.username() + \"\\\" />\");\n                        }\n                    }\n\n                    file.println(\"</project>\");\n                }\n                for (var namespace : census.namespaces()) {\n                    file.println(\"<namespace name=\\\"\" + namespace.name() + \"\\\">\");\n                    for (var entry : namespace.entries()) {\n                        var id = entry.getKey();\n                        var contributor = entry.getValue();\n                        file.println(\"  <user id=\\\"\" + id + \"\\\" census=\\\"\" + contributor.username() + \"\\\" />\");\n                    }\n                    file.println(\"</namespace>\");\n                }\n                file.println(\"</census>\");\n            }\n            toRepo.add(censusXML);\n            var head = toRepo.commit(\"Updated census.xml\", \"duke\", \"duke@openjdk.org\");\n            toRepo.push(head, to.authenticatedUrl(), Branch.defaultFor(VCS.GIT).name(), false);\n            last = fromRepo.head();\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        return List.of();\n    }\n\n    @Override\n    public String name() {\n        return CensusSyncBotFactory.NAME;\n    }\n\n    @Override\n    public String botName() {\n        return name();\n    }\n\n    @Override\n    public String workItemName() {\n        return \"unitfy\";\n    }\n}\n"
  },
  {
    "path": "bots/censussync/src/test/java/org/openjdk/skara/bots/censussync/CensusSyncBotFactoryTest.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.censussync;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.json.JWCC;\nimport org.openjdk.skara.test.TestBotFactory;\nimport org.openjdk.skara.test.TestHostedRepository;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass CensusSyncBotFactoryTest {\n    @Test\n    void testCreate() {\n        String jsonString = \"\"\"\n                {\n                    \"sync\": [\n                      {\n                        \"method\": \"unify\",\n                        \"from\": \"from1\",\n                        \"to\": \"to1\",\n                        \"version\": 1\n                      },\n                      {\n                        \"method\": \"split\",\n                        \"from\": \"https://test.org/test.xml\",\n                        \"to\": \"to2\",\n                        \"version\": 2\n                      }\n                    ]\n                }\n                \"\"\";\n        var jsonConfig = JWCC.parse(jsonString).asObject();\n\n        var testBotFactory = TestBotFactory.newBuilder()\n                .addHostedRepository(\"from1\", new TestHostedRepository(\"from1\"))\n                .addHostedRepository(\"to1\", new TestHostedRepository(\"to1\"))\n                .addHostedRepository(\"to2\", new TestHostedRepository(\"to2\"))\n                .build();\n\n        var bots = testBotFactory.createBots(CensusSyncBotFactory.NAME, jsonConfig);\n        assertEquals(2, bots.size());\n\n        var censusSyncUnifyBots = bots.stream().filter(e -> e.getClass().equals(CensusSyncUnifyBot.class)).toList();\n        var censusSyncSplitBots = bots.stream().filter(e -> e.getClass().equals(CensusSyncSplitBot.class)).toList();\n\n        assertEquals(1, censusSyncUnifyBots.size());\n        assertEquals(1, censusSyncSplitBots.size());\n\n        assertEquals(\"CensusSyncUnifyBot(from1->to1@1)\", censusSyncUnifyBots.get(0).toString());\n        assertEquals(\"CensusSyncSplitBot(https://test.org/test.xml->to2@2)\", censusSyncSplitBots.get(0).toString());\n    }\n}"
  },
  {
    "path": "bots/checkout/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.checkout'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.bots.checkout' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':bot')\n    implementation project(':ci')\n    implementation project(':vcs')\n    implementation project(':host')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':census')\n    implementation project(':process')\n    implementation project(':json')\n    implementation project(':network')\n    implementation project(':storage')\n    implementation project(':metrics')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "bots/checkout/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.bots.checkout {\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.host;\n    requires org.openjdk.skara.network;\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.process;\n    requires org.openjdk.skara.storage;\n    requires java.logging;\n\n    provides org.openjdk.skara.bot.BotFactory with org.openjdk.skara.bots.checkout.CheckoutBotFactory;\n}\n"
  },
  {
    "path": "bots/checkout/src/main/java/org/openjdk/skara/bots/checkout/CheckoutBot.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\npackage org.openjdk.skara.bots.checkout;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.convert.*;\nimport org.openjdk.skara.storage.StorageBuilder;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.util.*;\nimport java.nio.file.*;\nimport java.nio.charset.StandardCharsets;\nimport java.net.URI;\nimport java.net.URLEncoder;\nimport java.util.logging.Logger;\n\npublic class CheckoutBot implements Bot, WorkItem {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n    private final HostedRepository from;\n    private final Branch branch;\n    private final Path to;\n    private final Path storage;\n    private final StorageBuilder<Mark> marksStorage;\n\n    CheckoutBot(HostedRepository from, Branch branch, Path to, Path storage, StorageBuilder<Mark> marksStorage) {\n        this.from = from;\n        this.branch = branch;\n        this.to = to;\n        this.storage = storage;\n        this.marksStorage = marksStorage;\n    }\n\n    private static String urlEncode(Path p) {\n        return URLEncoder.encode(p.toString(), StandardCharsets.UTF_8);\n    }\n\n    private static String urlEncode(URI uri) {\n        return URLEncoder.encode(uri.toString(), StandardCharsets.UTF_8);\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof CheckoutBot o)) {\n            return true;\n        }\n        return !(o.to.equals(to) || o.from.equals(from));\n    }\n\n    @Override\n    public String toString() {\n        return \"CheckoutBot(\" + from.name() + \":\" + branch.name() + \", \" + to + \")\";\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        return List.of(this);\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratch) {\n        try {\n            var fromDir = storage.resolve(urlEncode(from.url()));\n            Repository fromRepo = null;\n            if (!Files.exists(fromDir)) {\n                Files.createDirectories(fromDir);\n                log.info(\"Cloning Git repo \" + from + \" to \" + fromDir);\n                fromRepo = Repository.clone(from.authenticatedUrl(), fromDir);\n            } else {\n                log.info(\"Getting existing Git repo repository from \" + fromDir);\n                fromRepo = Repository.get(fromDir).orElseThrow(() ->\n                    new IllegalStateException(\"Git repository vanished from \" + fromDir));\n            }\n            fromRepo.fetchRemote(\"origin\");\n            fromRepo.checkout(branch);\n            fromRepo.pull(\"origin\", branch.name(), true);\n\n            var toRepoName = to.getFileName().toString();\n            var marksDir = scratch.resolve(\"checkout\").resolve(\"marks\").resolve(toRepoName);\n            Files.createDirectories(marksDir);\n            var marks = marksStorage.materialize(marksDir);\n            var converter = new GitToHgConverter(branch);\n            var hasConverted = false;\n            try {\n                if (!Files.exists(to)) {\n                    log.info(\"Creating Hg repository at: \" + to);\n                    Files.createDirectories(to);\n                    var toRepo = Repository.init(to, VCS.HG);\n                    converter.convert(fromRepo, toRepo);\n                    hasConverted = true;\n                } else {\n                    log.info(\"Found existing Hg repository at: \" + to);\n                    var toRepo = Repository.get(to).orElseThrow(() ->\n                        new IllegalStateException(\"Repository vanished from \" + to));\n                    var existing = new ArrayList<>(marks.current());\n                    Collections.sort(existing);\n                    log.info(\"Found \" + existing.size() + \" existing marks\");\n                    converter.convert(fromRepo, toRepo, existing);\n                    hasConverted = true;\n                }\n            } finally {\n                if (hasConverted) {\n                    log.info(\"Storing \" + converter.marks().size() + \" marks\");\n                    marks.put(converter.marks());\n                } else {\n                    log.info(\"No conversion has taken place, not updating marks\");\n                }\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        return List.of();\n    }\n\n    @Override\n    public String name() {\n        return CheckoutBotFactory.NAME;\n    }\n\n    @Override\n    public String botName() {\n        return name();\n    }\n\n    @Override\n    public String workItemName() {\n        return botName();\n    }\n}\n"
  },
  {
    "path": "bots/checkout/src/main/java/org/openjdk/skara/bots/checkout/CheckoutBotFactory.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.checkout;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.convert.Mark;\n\nimport java.util.*;\nimport java.net.URI;\nimport java.nio.file.Path;\nimport java.util.logging.Logger;\n\npublic class CheckoutBotFactory implements BotFactory {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");\n\n    static final String NAME = \"checkout\";\n    @Override\n    public String name() {\n        return NAME;\n    }\n\n    @Override\n    public List<Bot> create(BotConfiguration configuration) {\n        var specific = configuration.specific();\n        var storage = configuration.storageFolder();\n\n        var marksRepo = configuration.repository(specific.get(\"marks\").get(\"repo\").asString());\n        var marksUser = Author.fromString(specific.get(\"marks\").get(\"author\").asString());\n\n        var bots = new ArrayList<Bot>();\n        for (var repo : specific.get(\"repositories\").asArray()) {\n            var from = configuration.repository(repo.get(\"from\").get(\"repo\").asString());\n            var fromBranch = new Branch(repo.get(\"from\").get(\"branch\").asString());\n            var to = Path.of(repo.get(\"to\").asString());\n\n            var toRepoName = to.getFileName().toString();\n            var markStorage = MarkStorage.create(marksRepo, marksUser, toRepoName);\n\n            bots.add(new CheckoutBot(from, fromBranch, to, storage, markStorage));\n        }\n\n        return bots;\n    }\n}\n"
  },
  {
    "path": "bots/checkout/src/main/java/org/openjdk/skara/bots/checkout/MarkStorage.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\npackage org.openjdk.skara.bots.checkout;\n\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.convert.Mark;\nimport org.openjdk.skara.storage.StorageBuilder;\n\nimport java.net.URI;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\nclass MarkStorage {\n    private static Mark deserializeMark(String s) {\n        var parts = s.split(\" \");\n        if (!(parts.length == 3 || parts.length == 4)) {\n            throw new IllegalArgumentException(\"Unexpected string:\" + s);\n        }\n\n        var key = Integer.parseInt(parts[0]);\n        var hg = new Hash(parts[1]);\n        var git = new Hash(parts[2]);\n\n        return parts.length == 3 ? new Mark(key, hg, git) : new Mark(key, hg, git, new Hash(parts[3]));\n    }\n\n    private static String serialize(Collection<Mark> added, Set<Mark> existing) {\n        var marks = new ArrayList<Mark>();\n        var handled = new HashSet<Integer>();\n        for (var mark : added) {\n            marks.add(mark);\n            handled.add(mark.key());\n        }\n        for (var mark : existing) {\n            if (!handled.contains(mark.key())) {\n                marks.add(mark);\n            }\n        }\n        Collections.sort(marks);\n        var sb = new StringBuilder();\n        for (var mark : marks) {\n            sb.append(Integer.toString(mark.key()));\n            sb.append(\" \");\n            sb.append(mark.hg().hex());\n            sb.append(\" \");\n            sb.append(mark.git().hex());\n            if (mark.tag().isPresent()) {\n                sb.append(\" \");\n                sb.append(mark.tag().get().hex());\n            }\n            sb.append(\"\\n\");\n        }\n        return sb.toString();\n    }\n\n    private static Set<Mark> deserialize(String current) {\n        var res = current.lines()\n                         .map(MarkStorage::deserializeMark)\n                         .collect(Collectors.toSet());\n        return res;\n    }\n\n    static StorageBuilder<Mark> create(HostedRepository repo, Author user, String name) {\n        return new StorageBuilder<Mark>(name + \"/marks.txt\")\n            .remoteRepository(repo, Branch.defaultFor(VCS.GIT).name(), user.name(), user.email(), \"Updated marks for \" + name)\n            .serializer(MarkStorage::serialize)\n            .deserializer(MarkStorage::deserialize);\n    }\n}\n"
  },
  {
    "path": "bots/checkout/src/test/java/org/openjdk/skara/bots/checkout/CheckoutBotFactoryTest.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.checkout;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.json.JWCC;\nimport org.openjdk.skara.test.TestBotFactory;\nimport org.openjdk.skara.test.TestHostedRepository;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass CheckoutBotFactoryTest {\n    @Test\n    public void testCreate() {\n        String jsonString = \"\"\"\n                {\n                  \"marks\": {\n                    \"repo\": \"mark\",\n                    \"author\": \"test_author <test_author@test.com>\"\n                  },\n                  \"repositories\": [\n                    {\n                      \"from\": {\n                        \"repo\": \"from1\",\n                        \"branch\": \"master\"\n                      },\n                      \"to\": \"to1\"\n                    },\n                    {\n                      \"from\": {\n                        \"repo\": \"from2\",\n                        \"branch\": \"dev\"\n                      },\n                      \"to\": \"to2\"\n                    }\n                  ]\n                }\n                \"\"\";\n        var jsonConfig = JWCC.parse(jsonString).asObject();\n\n        var testBotFactory = TestBotFactory.newBuilder()\n                .addHostedRepository(\"mark\", new TestHostedRepository(\"mark\"))\n                .addHostedRepository(\"from1\", new TestHostedRepository(\"from1\"))\n                .addHostedRepository(\"from2\", new TestHostedRepository(\"from2\"))\n                .build();\n\n        var bots = testBotFactory.createBots(CheckoutBotFactory.NAME, jsonConfig);\n        // A checkoutBot for every configured repository\n        assertEquals(2, bots.size());\n\n        assertEquals(\"CheckoutBot(from1:master, to1)\", bots.get(0).toString());\n        assertEquals(\"CheckoutBot(from2:dev, to2)\", bots.get(1).toString());\n    }\n}"
  },
  {
    "path": "bots/checkout/src/test/java/org/openjdk/skara/bots/checkout/CheckoutBotTests.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.checkout;\n\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.vcs.*;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.util.*;\nimport static java.nio.file.StandardOpenOption.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass CheckoutBotTests {\n    private static void populate(Repository r) throws IOException {\n        var readme = r.root().resolve(\"README\");\n        Files.write(readme, List.of(\"Hello, readme!\"));\n\n        r.add(readme);\n        r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n        Files.write(readme, List.of(\"Another line\"), WRITE, APPEND);\n        r.add(readme);\n        r.commit(\"Modify README\", \"duke\", \"duke@openjdk.org\");\n\n        Files.write(readme, List.of(\"A final line\"), WRITE, APPEND);\n        r.add(readme);\n        r.commit(\"Final README\", \"duke\", \"duke@openjdk.org\");\n    }\n\n    @Test\n    void simpleConversion(TestInfo testInfo) throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n            var marksLocalDir = tmp.path().resolve(\"marks.git\");\n            Files.createDirectories(marksLocalDir);\n            var marksLocalRepo = TestableRepository.init(marksLocalDir, VCS.GIT);\n            marksLocalRepo.config(\"receive\", \"denyCurrentBranch\", \"ignore\");\n            var marksHostedRepo = new TestHostedRepository(host, \"marks\", marksLocalRepo);\n\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"scratch\");\n            var marksAuthor = new Author(\"duke\", \"duke@openjdk.org\");\n            var marksStorage = MarkStorage.create(marksHostedRepo, marksAuthor, \"test\");\n\n            var hgDir = tmp.path().resolve(\"hg\");\n\n            var gitLocalDir = tmp.path().resolve(\"from.git\");\n            Files.createDirectories(gitLocalDir);\n            var gitLocalRepo = TestableRepository.init(gitLocalDir, VCS.GIT);\n            populate(gitLocalRepo);\n            var gitHostedRepo = new TestHostedRepository(host, \"from\", gitLocalRepo);\n\n            var bot = new CheckoutBot(gitHostedRepo, gitLocalRepo.defaultBranch(), hgDir, storage, marksStorage);\n            var runner = new TestBotRunner();\n            runner.runPeriodicItems(bot);\n\n            var hgRepo = Repository.get(hgDir).orElseThrow();\n            assertEquals(3, hgRepo.commitMetadata().size());\n        }\n    }\n\n    @Test\n    void update(TestInfo testInfo) throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n            var marksLocalDir = tmp.path().resolve(\"marks.git\");\n            Files.createDirectories(marksLocalDir);\n            var marksLocalRepo = TestableRepository.init(marksLocalDir, VCS.GIT);\n            marksLocalRepo.config(\"receive\", \"denyCurrentBranch\", \"ignore\");\n            var marksHostedRepo = new TestHostedRepository(host, \"marks\", marksLocalRepo);\n\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"scratch\");\n            var marksAuthor = new Author(\"duke\", \"duke@openjdk.org\");\n            var marksStorage = MarkStorage.create(marksHostedRepo, marksAuthor, \"test\");\n            var runner = new TestBotRunner();\n\n            var hgDir = tmp.path().resolve(\"hg\");\n\n            var gitLocalDir = tmp.path().resolve(\"from.git\");\n            Files.createDirectories(gitLocalDir);\n            var gitLocalRepo = TestableRepository.init(gitLocalDir, VCS.GIT);\n            populate(gitLocalRepo);\n            var gitHostedRepo = new TestHostedRepository(host, \"from\", gitLocalRepo);\n\n            var bot = new CheckoutBot(gitHostedRepo, gitLocalRepo.defaultBranch(), hgDir, storage, marksStorage);\n            runner.runPeriodicItems(bot);\n\n            var hgRepo = Repository.get(hgDir).orElseThrow();\n            assertEquals(3, hgRepo.commitMetadata().size());\n            assertEquals(3, gitLocalRepo.commitMetadata().size());\n\n            var readme = gitLocalRepo.root().resolve(\"README\");\n            Files.write(readme, List.of(\"An updated line\"), WRITE, APPEND);\n            gitLocalRepo.add(readme);\n            gitLocalRepo.commit(\"Updated Final README\", \"duke\", \"duke@openjdk.org\");\n\n            runner.runPeriodicItems(bot);\n            assertEquals(4, hgRepo.commitMetadata().size());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/cli/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nplugins {\n    id 'skara-images'\n}\n\nmodule {\n    name = 'org.openjdk.skara.bots.cli'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        requires 'jdk.httpserver'\n        opens 'org.openjdk.skara.bots.cli' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':bots:pr')\n    implementation project(':bots:hgbridge')\n    implementation project(':bots:forward')\n    implementation project(':bots:notify')\n    implementation project(':bots:merge')\n    implementation project(':bots:mlbridge')\n    implementation project(':bots:mirror')\n    implementation project(':bots:topological')\n    implementation project(':bots:tester')\n    implementation project(':bots:submit')\n    implementation project(':bots:forward')\n    implementation project(':bots:bridgekeeper')\n    implementation project(':bots:checkout')\n    implementation project(':bots:censussync')\n    implementation project(':bots:testinfo')\n    implementation project(':bots:synclabel')\n    implementation project(':ci')\n    implementation project(':vcs')\n    implementation project(':jcheck')\n    implementation project(':host')\n    implementation project(':network')\n    implementation project(':bot')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':census')\n    implementation project(':json')\n    implementation project(':ini')\n    implementation project(':process')\n    implementation project(':args')\n    implementation project(':proxy')\n    implementation project(':version')\n    implementation project(':metrics')\n\n    testImplementation project(':test')\n}\n\n// Load deps.env and remove all double quotes\ndef depsEnv = new Properties()\nfile(\"../../deps.env\").withInputStream { { depsEnv.load(it)}}\ndepsEnv.entrySet().forEach(e -> e.setValue(((String) e.getValue()).replaceAll(\"\\\"\", \"\")))\n\nimages {\n    linux_x64 {\n        modules = ['jdk.crypto.ec',\n                   'org.openjdk.skara.bots.pr',\n                   'org.openjdk.skara.bots.hgbridge',\n                   'org.openjdk.skara.bots.forward',\n                   'org.openjdk.skara.bots.notify',\n                   'org.openjdk.skara.bots.merge',\n                   'org.openjdk.skara.bots.mlbridge',\n                   'org.openjdk.skara.bots.mirror',\n                   'org.openjdk.skara.bots.submit',\n                   'org.openjdk.skara.bots.tester',\n                   'org.openjdk.skara.bots.topological',\n                   'org.openjdk.skara.bots.forward',\n                   'org.openjdk.skara.bots.bridgekeeper',\n                   'org.openjdk.skara.bots.checkout',\n                   'org.openjdk.skara.bots.censussync',\n                   'org.openjdk.skara.bots.testinfo',\n                   'org.openjdk.skara.bots.synclabel']\n        launchers = ['skara-bots': 'org.openjdk.skara.bots.cli/org.openjdk.skara.bots.cli.BotLauncher']\n        options = [\"--module-path\", \"plugins\"]\n        bundles = ['zip', 'tar.gz']\n        jdk {\n            url = depsEnv.getProperty(\"JDK_LINUX_X64_URL\")\n            sha256 = depsEnv.getProperty(\"JDK_LINUX_X64_SHA256\")\n        }\n    }\n}\n"
  },
  {
    "path": "bots/cli/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.bots.cli {\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.jcheck;\n    requires org.openjdk.skara.host;\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.census;\n    requires org.openjdk.skara.json;\n    requires org.openjdk.skara.args;\n    requires org.openjdk.skara.process;\n    requires org.openjdk.skara.proxy;\n    requires org.openjdk.skara.network;\n    requires org.openjdk.skara.version;\n\n    requires java.net.http;\n    requires java.sql;\n\n    exports org.openjdk.skara.bots.cli;\n}\n\n"
  },
  {
    "path": "bots/cli/src/main/java/org/openjdk/skara/bots/cli/BotConsoleHandler.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.cli;\n\nimport java.time.ZoneId;\nimport java.time.format.DateTimeFormatter;\nimport java.time.temporal.ChronoUnit;\nimport java.util.*;\nimport java.util.logging.*;\n\nclass BotConsoleHandler extends StreamHandler {\n\n    private final DateTimeFormatter dateTimeFormatter;\n    private final Map<Integer, String> levelAbbreviations;\n\n    BotConsoleHandler() {\n        dateTimeFormatter = DateTimeFormatter.ISO_INSTANT\n                .withLocale(Locale.getDefault())\n                .withZone(ZoneId.systemDefault());\n\n        levelAbbreviations = new HashMap<>();\n        levelAbbreviations.put(Level.INFO.intValue(), \"I\");\n        levelAbbreviations.put(Level.FINE.intValue(), \"F\");\n        levelAbbreviations.put(Level.FINER.intValue(), \"finer\");\n        levelAbbreviations.put(Level.FINEST.intValue(), \"finest\");\n        levelAbbreviations.put(Level.SEVERE.intValue(), \"E\");\n        levelAbbreviations.put(Level.WARNING.intValue(), \"W\");\n    }\n\n    @Override\n    public void publish(LogRecord record) {\n        if (record.getLevel().intValue() < getLevel().intValue()) {\n            return;\n        }\n\n        var level = levelAbbreviations.getOrDefault(record.getLevel().intValue(), \"?\");\n        System.out.println(\"[\" + dateTimeFormatter.format(record.getInstant().truncatedTo(ChronoUnit.SECONDS)) + \"][\" + record.getLongThreadID() + \"][\" +\n                level + \"] \" + record.getMessage());\n        var exception = record.getThrown();\n        if (exception != null) {\n            exception.printStackTrace(System.out);\n        }\n        System.out.flush();\n    }\n}\n"
  },
  {
    "path": "bots/cli/src/main/java/org/openjdk/skara/bots/cli/BotLauncher.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.cli;\n\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.time.format.DateTimeFormatter;\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.proxy.HttpProxy;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.concurrent.TimeoutException;\nimport java.util.function.Function;\nimport java.util.logging.*;\nimport java.util.stream.*;\n\npublic class BotLauncher {\n    private static Logger log;\n    private static final Instant START_TIME = Instant.now();\n\n    private static void applyLogging(JSONObject config) {\n        LogManager.getLogManager().reset();\n        log = Logger.getLogger(\"org.openjdk\");\n        log.setLevel(Level.FINEST);\n\n        if (!config.contains(\"log\")) {\n            return;\n        }\n\n        if (config.get(\"log\").asObject().contains(\"console\")) {\n            var level = Level.parse(config.get(\"log\").get(\"console\").get(\"level\").asString());\n            var handler = new BotConsoleHandler();\n            handler.setLevel(level);\n            log.addHandler(handler);\n        }\n\n        if (config.get(\"log\").asObject().contains(\"slack\")) {\n            var maxRate = Duration.ofMinutes(10);\n            JSONValue slack = config.get(\"log\").get(\"slack\");\n            if (slack.asObject().contains(\"maxrate\")) {\n                maxRate = Duration.parse(slack.get(\"maxrate\").asString());\n            }\n            var level = Level.parse(slack.get(\"level\").asString());\n            Map<String, String> details = new HashMap<>();\n            if (slack.asObject().contains(\"details\")) {\n                details = slack.get(\"details\").asArray().stream()\n                                .collect(Collectors.toMap(o -> o.get(\"pattern\").asString(),\n                                                          o -> o.get(\"link\").asString()));\n            }\n            var username = slack.get(\"username\");\n            var prefix = slack.get(\"prefix\");\n            var handler = new BotSlackHandler(URIBuilder.base(slack.get(\"webhook\").asString()).build(),\n                    username == null ? null : username.asString(),\n                    prefix == null ? null : prefix.asString(),\n                    maxRate,\n                    details);\n            handler.setLevel(level);\n            log.addHandler(handler);\n        }\n\n        if (config.get(\"log\").asObject().contains(\"logstash\")) {\n            var logstashConf = config.get(\"log\").get(\"logstash\").asObject();\n            var level = Level.parse(logstashConf.get(\"level\").asString());\n            var handler = new BotLogstashHandler(URIBuilder.base(logstashConf.get(\"endpoint\").asString()).build());\n            if (logstashConf.contains(\"fields\")) {\n                for (var field : logstashConf.get(\"fields\").asArray()) {\n                    if (field.asObject().contains(\"pattern\")) {\n                        handler.addExtraField(field.get(\"name\").asString(),\n                                field.get(\"value\").asString(),\n                                field.get(\"pattern\").asString());\n                    } else {\n                        handler.addExtraField(field.get(\"name\").asString(),\n                                field.get(\"value\").asString());\n                    }\n                }\n            }\n            if (logstashConf.contains(\"replacements\")) {\n                for (var field : logstashConf.get(\"replacements\").asArray()) {\n                    handler.addReplacement(field.get(\"pattern\").asString(), field.get(\"replacement\").asString());\n                }\n            }\n            handler.setLevel(level);\n            var dateTimeFormatter = DateTimeFormatter.ISO_INSTANT\n                    .withLocale(Locale.getDefault())\n                    .withZone(ZoneId.systemDefault());\n            handler.addExtraField(\"instance_start_time\", dateTimeFormatter.format(START_TIME));\n            log.addHandler(handler);\n        }\n    }\n\n    private static JSONObject readConfiguration(Path jsonFile) {\n        try {\n            return JWCC.parse(Files.readString(jsonFile)).asObject();\n        } catch (IOException e) {\n            throw new RuntimeException(\"Failed to open configuration file: \" + jsonFile);\n        }\n    }\n\n    public static void main(String... args) {\n        HttpProxy.setup();\n\n        var flags = List.of(\n                Option.shortcut(\"t\")\n                      .fullname(\"timeout\")\n                      .describe(\"ISO8601\")\n                      .helptext(\"When running once, only run for this long (default 1 hour)\")\n                      .optional(),\n                Switch.shortcut(\"o\")\n                      .fullname(\"once\")\n                      .helptext(\"Instead of repeatedly executing periodical task, run each task exactly once\")\n                      .optional(),\n                Switch.shortcut(\"v\")\n                      .fullname(\"version\")\n                      .helptext(\"Show version\")\n                      .optional(),\n                Switch.shortcut(\"l\")\n                      .fullname(\"list-bots\")\n                      .helptext(\"List all available bots and then exit\")\n                      .optional());\n        var inputs = List.of(\n                Input.position(0)\n                     .describe(\"configuration.json\")\n                     .singular()\n                     .required());\n        var parser = new ArgumentParser(\"bots\", flags, inputs);\n        var arguments = parser.parse(args);\n\n        if (arguments.contains(\"list-bots\")) {\n            var botFactories = BotFactory.getBotFactories();\n            System.out.println(\"Number of available bots: \" + botFactories.size());\n            for (var botFactory : botFactories) {\n                System.out.println(\" - \" + botFactory.name() + \" (\" + botFactory.getClass().getModule() + \")\");\n            }\n            System.exit(0);\n        }\n\n        if (arguments.contains(\"version\")) {\n            System.out.println(Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        Path jsonFile = arguments.at(0).via(Paths::get);\n        var jsonConfig = readConfiguration(jsonFile);\n\n        applyLogging(jsonConfig);\n        var log = Logger.getLogger(\"org.openjdk.skara.bots.cli\");\n        log.info(\"Starting BotLauncher\");\n\n        BotRunnerConfiguration runnerConfig = null;\n        try {\n            runnerConfig = BotRunnerConfiguration.parse(jsonConfig, jsonFile.getParent());\n        } catch (ConfigurationError configurationError) {\n            log.severe(\"Failed to parse configuration file: \" + jsonFile\n                    + \" error message: \" + configurationError.getMessage());\n            // Also print directly as logging may not be setup\n            System.out.println(\"Failed to parse configuration file: \" + jsonFile);\n            System.out.println(\"Error message: \" + configurationError.getMessage());\n            System.exit(1);\n        }\n\n        var botFactories = BotFactory.getBotFactories().stream()\n                                     .collect(Collectors.toMap(BotFactory::name, Function.identity()));\n        if (botFactories.size() == 0) {\n            log.severe(\"Error: no bot factories found. Make sure the module path is correct. Exiting...\");\n            // Also print directly as logging may not be setup\n            System.out.println(\"Error: no bot factories found. Make sure the module path is correct. Exiting...\");\n            System.exit(1);\n        }\n\n        var bots = new ArrayList<Bot>();\n\n        for (var botEntry : botFactories.entrySet()) {\n            try {\n                var botConfig = runnerConfig.perBotConfiguration(botEntry.getKey());\n                bots.addAll(botEntry.getValue().create(botConfig));\n            } catch (ConfigurationError configurationError) {\n                log.info(\"No configuration for available bot '\" + botEntry.getKey() + \"', skipping...\");\n            }\n        }\n\n        var runner = new BotRunner(runnerConfig, bots);\n\n        try {\n            if (arguments.contains(\"once\")) {\n                runner.runOnce(arguments.get(\"timeout\").or(\"PT60M\").via(Duration::parse));\n            } else {\n                runner.run();\n            }\n        } catch (TimeoutException e) {\n            e.printStackTrace();\n        }\n    }\n}\n"
  },
  {
    "path": "bots/cli/src/main/java/org/openjdk/skara/bots/cli/BotLogstashHandler.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.cli;\n\nimport org.openjdk.skara.bot.LogContextMap;\nimport org.openjdk.skara.json.JSON;\n\nimport java.io.PrintWriter;\nimport java.io.StringWriter;\nimport java.net.URI;\nimport java.net.http.*;\nimport java.time.*;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\nimport java.util.concurrent.Future;\nimport java.util.logging.*;\nimport java.util.regex.Pattern;\n\n/**\n * Handles logging to logstash. Be careful not to call anything that creates new\n * log records from this class as that can cause infinite recursion.\n */\npublic class BotLogstashHandler extends StreamHandler {\n    private final URI endpoint;\n    private final HttpClient httpClient;\n    private final DateTimeFormatter dateTimeFormatter;\n    // Optionally store all futures for testing purposes\n    private Collection<Future<HttpResponse<Void>>> futures;\n\n    private record RegexReplacement(Pattern pattern, String replacement) {}\n\n    private final List<RegexReplacement> regexReplacements = new ArrayList<>();\n\n    private static class ExtraField {\n        String name;\n        String value;\n        Pattern pattern;\n    }\n\n    private final List<ExtraField> extraFields;\n\n    BotLogstashHandler(URI endpoint) {\n        this.endpoint = endpoint;\n        this.httpClient = HttpClient.newBuilder()\n                .followRedirects(HttpClient.Redirect.NORMAL)\n                .connectTimeout(Duration.ofSeconds(30))\n                .build();\n        dateTimeFormatter = DateTimeFormatter.ISO_INSTANT\n                .withLocale(Locale.getDefault())\n                .withZone(ZoneId.systemDefault());\n        extraFields = new ArrayList<>();\n    }\n\n    void addExtraField(String name, String value) {\n        addExtraField(name, value, null);\n    }\n\n    void addExtraField(String name, String value, String pattern) {\n        var extraField = new ExtraField();\n        extraField.name = name;\n        extraField.value = value;\n        if (pattern != null) {\n            extraField.pattern = Pattern.compile(pattern);\n        }\n        extraFields.add(extraField);\n    }\n\n    void addReplacement(String pattern, String replacement) {\n        regexReplacements.add(new RegexReplacement(Pattern.compile(pattern), replacement));\n    }\n\n    private Map<String, String> getExtraFields(LogRecord record) {\n        var ret = new HashMap<String, String>();\n        for (var extraField : extraFields) {\n            if (extraField.pattern != null) {\n                var matcher = extraField.pattern.matcher(record.getMessage());\n                if (matcher.matches()) {\n                    var value = matcher.replaceFirst(extraField.value);\n                    ret.put(extraField.name, value);\n                }\n            } else {\n                ret.put(extraField.name, extraField.value);\n            }\n        }\n        return ret;\n    }\n\n    private String applyReplacements(String s) {\n        CharSequence ret = s;\n        for (RegexReplacement regexReplacement : regexReplacements) {\n            var matcher = regexReplacement.pattern.matcher(ret);\n            var sb = new StringBuilder();\n            while (matcher.find()) {\n                matcher.appendReplacement(sb, regexReplacement.replacement);\n            }\n            matcher.appendTail(sb);\n            ret = sb;\n        }\n        return ret.toString();\n    }\n\n    @Override\n    public void publish(LogRecord record) {\n        if (record.getLevel().intValue() < getLevel().intValue()) {\n            return;\n        }\n        Level level = record.getLevel();\n        var query = JSON.object();\n        query.put(\"@timestamp\", dateTimeFormatter.format(record.getInstant()));\n        query.put(\"level\", level.getName());\n        query.put(\"level_value\", level.intValue());\n        query.put(\"message\", applyReplacements(record.getMessage()));\n\n        if (record.getLoggerName() != null) {\n            query.put(\"logger_name\", record.getLoggerName());\n        }\n\n        var parameters = record.getParameters();\n        if (parameters != null) {\n            for (var parameter : parameters) {\n                if (parameter instanceof Duration duration) {\n                    query.put(\"duration\", duration.toMillis());\n                }\n            }\n        }\n\n        if (record.getThrown() != null) {\n            var writer = new StringWriter();\n            var printer = new PrintWriter(writer);\n            record.getThrown().printStackTrace(printer);\n            query.put(\"stack_trace\", writer.toString());\n        }\n\n        for (var entry : LogContextMap.entrySet()) {\n            query.put(entry.getKey(), entry.getValue());\n        }\n\n        for (var extraField : getExtraFields(record).entrySet()) {\n            query.put(extraField.getKey(), extraField.getValue());\n        }\n\n        var httpRequest = HttpRequest.newBuilder()\n                .uri(endpoint)\n                .header(\"Content-Type\", \"application/json\")\n                .POST(HttpRequest.BodyPublishers.ofString(query.toString()))\n                .build();\n        var future = httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.discarding());\n        // Save futures in optional collection when running tests.\n        if (futures != null) {\n            futures.add(future);\n        }\n    }\n\n    void setFuturesCollection(Collection<Future<HttpResponse<Void>>> futures) {\n        this.futures = futures;\n    }\n}\n"
  },
  {
    "path": "bots/cli/src/main/java/org/openjdk/skara/bots/cli/BotSlackHandler.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.cli;\n\nimport org.openjdk.skara.bot.BotTaskAggregationHandler;\nimport org.openjdk.skara.network.*;\nimport org.openjdk.skara.json.JSON;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.time.*;\nimport java.util.*;\nimport java.util.logging.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\nclass BotSlackHandler extends BotTaskAggregationHandler {\n\n    private final RestRequest webhook;\n    private final String username;\n    private final String prefix;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.cli\");;\n    private final Duration minimumSeparation;\n    private final Map<Pattern, String> linkPatterns;\n    private Instant lastUpdate;\n    private int dropCount;\n\n    BotSlackHandler(URI webhookUrl, String username, String prefix, Duration minimumSeparation, Map<String, String> links) {\n        super(true);\n        webhook = new RestRequest(webhookUrl);\n        this.username = username;\n        this.prefix = prefix;\n        this.minimumSeparation = minimumSeparation;\n        linkPatterns = links.entrySet().stream()\n                            .collect(Collectors.toMap(entry -> Pattern.compile(entry.getKey(),\n                                                                               Pattern.MULTILINE | Pattern.DOTALL),\n                                                      Map.Entry::getValue));\n        lastUpdate = Instant.EPOCH;\n        dropCount = 0;\n    }\n\n    private Optional<String> getLink(String message) {\n        for (var linkPattern : linkPatterns.entrySet()) {\n            var matcher = linkPattern.getKey().matcher(message);\n            if (matcher.find()) {\n                return Optional.of(matcher.replaceFirst(linkPattern.getValue()));\n            }\n        }\n        return Optional.empty();\n    }\n\n    private void publishToSlack(String message) {\n        try {\n            if (lastUpdate.plus(minimumSeparation).isAfter(Instant.now())) {\n                dropCount++;\n                return;\n            }\n\n            if (dropCount > 0) {\n                message = \"_*\" + dropCount + \"* previous message(s) silently dropped due to throttling_\\n\" +\n                        message;\n            }\n            lastUpdate = Instant.now();\n            dropCount = 0;\n\n            var query = JSON.object();\n            query.put(\"text\", message);\n            if (username != null) {\n                query.put(\"username\", username);\n            }\n\n            var link = getLink(message);\n            if (link.isPresent()) {\n                var attachment = JSON.object();\n                attachment.put(\"fallback\", \"Details link\");\n                attachment.put(\"color\", \"#cc0e31\");\n                attachment.put(\"title\", \"Click for more details\");\n                attachment.put(\"title_link\", link.get());\n                var attachments = JSON.array();\n                attachments.add(attachment);\n                query.put(\"attachments\", attachments);\n            }\n\n            webhook.post(\"\").body(query).executeUnparsed();\n        } catch (RuntimeException | IOException e) {\n            log.log(Level.WARNING, \"Exception during slack notification posting: \" + e.getMessage(), e);\n        }\n    }\n\n    @Override\n    public void publishAggregated(List<LogRecord> task) {\n        var message = task.stream()\n                            .map(this::formatMessage)\n                            .collect(Collectors.joining(\"\\n\"));\n        if (!message.isEmpty()) {\n            publishToSlack(message);\n        }\n    }\n\n    @Override\n    public void publishSingle(LogRecord record) {\n        publishToSlack(formatMessage(record));\n    }\n\n    private String formatMessage(LogRecord record) {\n        var message = new StringBuilder();\n        if (prefix != null) {\n            message.append(prefix);\n        }\n        message.append(\"`\").append(record.getLevel().getName()).append(\"` \").append(record.getMessage());\n        return message.toString();\n    }\n}\n"
  },
  {
    "path": "bots/cli/src/test/java/org/openjdk/skara/bots/cli/BotLogstashHandlerTests.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.cli;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.io.IOException;\nimport java.net.http.HttpResponse;\nimport java.util.ArrayList;\nimport java.util.Comparator;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.Future;\nimport java.util.logging.Level;\nimport java.util.logging.LogRecord;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass BotLogstashHandlerTests {\n\n    @Test\n    void simple() throws IOException, ExecutionException, InterruptedException {\n        try (var receiver = new RestReceiver()) {\n            var handler = new BotLogstashHandler(receiver.getEndpoint());\n            var futures = new ArrayList<Future<HttpResponse<Void>>>();\n            handler.setFuturesCollection(futures);\n\n            var record = new LogRecord(Level.INFO, \"Hello\");\n            record.setLoggerName(\"my.logger\");\n            handler.publish(record);\n\n            for (Future<HttpResponse<Void>> future : futures) {\n                future.get();\n            }\n\n            var requests = receiver.getRequests();\n            assertEquals(1, requests.size(), requests.toString());\n            assertEquals(\"Hello\", requests.get(0).get(\"message\").asString());\n            assertEquals(Level.INFO.getName(), requests.get(0).get(\"level\").asString());\n            assertEquals(\"my.logger\", requests.get(0).get(\"logger_name\").asString());\n        }\n    }\n\n    @Test\n    void simpleTask() throws IOException, ExecutionException, InterruptedException {\n        try (var receiver = new RestReceiver()) {\n            var handler = new BotLogstashHandler(receiver.getEndpoint());\n            var futures = new ArrayList<Future<HttpResponse<Void>>>();\n            handler.setFuturesCollection(futures);\n\n            LoggingBot.runOnce(handler, log -> {\n                log.warning(\"Hello\");\n                log.warning(\"Warning!\");\n                log.warning(\"Bye\");\n            });\n\n            for (Future<HttpResponse<Void>> future : futures) {\n                future.get();\n            }\n\n            var requests = receiver.getRequests();\n            // The async message sending means we may get results in any order. Sort on the\n            // timestamp to get the actual order.\n            requests.sort(Comparator.comparing(r -> r.get(\"@timestamp\").toString()));\n\n            assertEquals(3, requests.size(), requests.toString());\n            assertEquals(Level.WARNING.getName(), requests.get(0).get(\"level\").asString());\n            assertEquals(Level.WARNING.intValue(), requests.get(0).get(\"level_value\").asInt());\n            assertEquals(\"Hello\", requests.get(0).get(\"message\").asString());\n            assertEquals(\"Warning!\", requests.get(1).get(\"message\").asString());\n            assertEquals(\"Bye\", requests.get(2).get(\"message\").asString());\n            assertEquals(Level.WARNING.toString(), requests.get(0).get(\"level\").asString());\n            assertNotNull(requests.get(0).get(\"work_id\"), \"work_id not set\");\n            assertTrue(requests.get(0).get(\"work_item\").asString().contains(\"LoggingBot\"),\n                    \"work_item has bad value \" + requests.get(0).get(\"work_item\").asString());\n        }\n    }\n\n    @Test\n    void extraField() throws IOException, ExecutionException, InterruptedException {\n        try (var receiver = new RestReceiver()) {\n            var handler = new BotLogstashHandler(receiver.getEndpoint());\n            var futures = new ArrayList<Future<HttpResponse<Void>>>();\n            handler.setFuturesCollection(futures);\n\n            handler.addExtraField(\"mandatory\", \"value\");\n            handler.addExtraField(\"optional1\", \"$1\", \"^H(ello)$\");\n            handler.addExtraField(\"optional2\", \"$1\", \"^(Not found)$\");\n            var record = new LogRecord(Level.INFO, \"Hello\");\n            handler.publish(record);\n\n            for (Future<HttpResponse<Void>> future : futures) {\n                future.get();\n            }\n\n            var requests = receiver.getRequests();\n            assertEquals(1, requests.size(), requests.toString());\n            assertEquals(\"value\", requests.get(0).get(\"mandatory\").asString());\n            assertEquals(\"ello\", requests.get(0).get(\"optional1\").asString());\n            assertFalse(requests.get(0).contains(\"optional2\"));\n        }\n    }\n\n    @Test\n    void extraFieldTask() throws IOException, ExecutionException, InterruptedException {\n        try (var receiver = new RestReceiver()) {\n            var handler = new BotLogstashHandler(receiver.getEndpoint());\n            var futures = new ArrayList<Future<HttpResponse<Void>>>();\n            handler.setFuturesCollection(futures);\n\n            handler.addExtraField(\"mandatory\", \"value\");\n            handler.addExtraField(\"optional1\", \"$1\", \"^H(ello)$\");\n            handler.addExtraField(\"optional2\", \"$1\", \"^(Not found)$\");\n            handler.addExtraField(\"optional3\", \"$1\", \"^B(ye)$\");\n            handler.addExtraField(\"greedy\", \"$1\", \"^(.*)$\");\n\n            LoggingBot.runOnce(handler, log -> {\n                log.warning(\"Hello\");\n                log.warning(\"Warning!\");\n                log.warning(\"Bye\");\n            });\n\n            for (Future<HttpResponse<Void>> future : futures) {\n                future.get();\n            }\n\n            var requests = receiver.getRequests();\n            // The async message sending means we may get results in any order. Sort on the\n            // timestamp to get the actual order.\n            requests.sort(Comparator.comparing(r -> r.get(\"@timestamp\").toString()));\n\n            assertEquals(3, requests.size(), requests.toString());\n            assertEquals(\"value\", requests.get(0).get(\"mandatory\").asString());\n            assertEquals(\"ello\", requests.get(0).get(\"optional1\").asString());\n            assertFalse(requests.get(0).contains(\"optional2\"));\n            assertEquals(\"ye\", requests.get(2).get(\"optional3\").asString());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/cli/src/test/java/org/openjdk/skara/bots/cli/BotSlackHandlerTests.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.cli;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.util.HashMap;\nimport java.util.logging.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass BotSlackHandlerTests {\n\n    @BeforeAll\n    static void setUp() {\n        Logger log = Logger.getGlobal();\n        log.setLevel(Level.FINER);\n        log = Logger.getLogger(\"org.openjdk.skara.bot\");\n        log.setLevel(Level.FINER);\n    }\n\n    @Test\n    void simple() throws IOException {\n\n        try (var receiver = new RestReceiver()) {\n            var handler = new BotSlackHandler(receiver.getEndpoint(), \"test\", \"`testc` \", Duration.ofSeconds(1), new HashMap<>());\n            var record = new LogRecord(Level.INFO, \"Hello\");\n            handler.publish(record);\n\n            var requests = receiver.getRequests();\n            assertEquals(1, requests.size(), requests.toString());\n            assertEquals(\"test\", requests.get(0).get(\"username\").asString());\n            assertEquals(\"`testc` `INFO` Hello\", requests.get(0).get(\"text\").asString());\n        }\n    }\n\n    @Test\n    void noUser() throws IOException {\n\n        try (var receiver = new RestReceiver()) {\n            var handler = new BotSlackHandler(receiver.getEndpoint(), null, null, Duration.ofSeconds(1), new HashMap<>());\n            var record = new LogRecord(Level.INFO, \"Hello\");\n            handler.publish(record);\n\n            var requests = receiver.getRequests();\n            assertEquals(1, requests.size(), requests.toString());\n            assertNull(requests.get(0).get(\"username\"));\n            assertEquals(\"`INFO` Hello\", requests.get(0).get(\"text\").asString());\n        }\n    }\n\n    @Test\n    void throttled() throws IOException, InterruptedException {\n        try (var receiver = new RestReceiver()) {\n            final var maxDuration = Duration.ofMillis(1500);\n            var handler = new BotSlackHandler(receiver.getEndpoint(), \"test\", null, maxDuration, new HashMap<>());\n\n            // Post until we hit throttling\n            var posted = 0;\n            var maxAttempts = 10000;\n            var wasThrottled = false;\n            while (posted < maxAttempts) {\n                var record = new LogRecord(Level.INFO, \"Hello\");\n                handler.publish(record);\n\n                posted++;\n                if (receiver.getRequests().size() != posted) {\n                    wasThrottled = true;\n                    break;\n                }\n            }\n            assertTrue(wasThrottled, \"Did not get throttled, is maxDuration too low?\");\n\n            var requests = receiver.getRequests();\n            var lastRequest = requests.getLast().asObject();\n            assertEquals(\"test\", lastRequest.get(\"username\").asString(), lastRequest.toString());\n            assertTrue(lastRequest.get(\"text\").asString().contains(\"Hello\"), lastRequest.toString());\n\n            Thread.sleep(maxDuration);\n            var record = new LogRecord(Level.INFO, \"Hello a final time!\");\n            handler.publish(record);\n            lastRequest = requests.getLast().asObject();\n            assertEquals(\"test\", lastRequest.get(\"username\").asString(), lastRequest.toString());\n            assertTrue(lastRequest.get(\"text\").asString().contains(\"Hello a final time!\"), lastRequest.toString());\n            assertTrue(lastRequest.get(\"text\").asString().contains(\"dropped\"), lastRequest.toString());\n        }\n    }\n\n    @Test\n    void unthrottled() throws IOException, InterruptedException {\n        try (var receiver = new RestReceiver()) {\n            var handler = new BotSlackHandler(receiver.getEndpoint(), \"test\", null, Duration.ofMillis(1), new HashMap<>());\n            var record = new LogRecord(Level.INFO, \"Hello\");\n            handler.publish(record);\n            Thread.sleep(10);\n            record = new LogRecord(Level.INFO, \"Hello again!\");\n            handler.publish(record);\n\n            var requests = receiver.getRequests();\n            assertEquals(2, requests.size());\n            assertEquals(\"test\", requests.get(0).get(\"username\").asString());\n            assertTrue(requests.get(0).get(\"text\").asString().contains(\"Hello\"));\n            assertEquals(\"test\", requests.get(1).get(\"username\").asString());\n            assertTrue(requests.get(1).get(\"text\").asString().contains(\"Hello again!\"));\n        }\n    }\n\n    @Test\n    void detailsLink() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            var details = new HashMap<String, String>();\n            details.put(\".*error: (xyz).*\", \"http://go.to/$1\");\n            var handler = new BotSlackHandler(receiver.getEndpoint(), \"test\", null, Duration.ofMillis(1), details);\n\n            var record = new LogRecord(Level.INFO, \"Something bad happened. error: xyz occurred\");\n            handler.publish(record);\n\n            var requests = receiver.getRequests();\n            assertEquals(1, requests.size(), requests.toString());\n            var request = requests.get(0).asObject();\n            assertEquals(\"test\", request.get(\"username\").asString());\n            assertTrue(request.get(\"text\").asString().contains(\"Something bad\"));\n            assertEquals(1, request.get(\"attachments\").asArray().size());\n\n            var attachment = request.get(\"attachments\").asArray().get(0);\n            assertTrue(attachment.get(\"title_link\").asString().contains(\"go.to/xyz\"));\n        }\n    }\n\n    @Test\n    void detailsNotMatching() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            var details = new HashMap<String, String>();\n            details.put(\".*error: (xyz).*\", \"http://go.to/$1\");\n            var handler = new BotSlackHandler(receiver.getEndpoint(), \"test\", null, Duration.ofMillis(1), details);\n\n            var record = new LogRecord(Level.INFO, \"Something bad happened. error: abc occurred\");\n            handler.publish(record);\n\n            var requests = receiver.getRequests();\n            assertEquals(1, requests.size(), requests.toString());\n            var request = requests.get(0).asObject();\n            assertEquals(\"test\", request.get(\"username\").asString());\n            assertTrue(request.get(\"text\").asString().contains(\"Something bad\"));\n            assertFalse(request.contains(\"attachments\"));\n        }\n    }\n\n    @Test\n    void taskLog() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            var handler = new BotSlackHandler(receiver.getEndpoint(), \"test\", null, Duration.ZERO, new HashMap<>());\n\n            LoggingBot.runOnce(handler, log -> {\n                log.warning(\"Hello\");\n                log.warning(\"Bye\");\n            });\n\n            var requests = receiver.getRequests();\n            assertEquals(1, requests.size(), requests.toString());\n            assertEquals(\"test\", requests.get(0).get(\"username\").asString());\n            assertTrue(requests.get(0).get(\"text\").asString().contains(\"Hello\"), requests.get(0).get(\"text\").asString());\n            assertTrue(requests.get(0).get(\"text\").asString().contains(\"Bye\"), requests.get(0).get(\"text\").asString());\n        }\n    }\n\n    @Test\n    void taskLogDetailsLink() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            var details = new HashMap<String, String>();\n            details.put(\"error: (def) occured$\", \"http://go.to/$1\");\n            var handler = new BotSlackHandler(receiver.getEndpoint(), \"test\", null, Duration.ZERO, details);\n\n            LoggingBot.runOnce(handler, log -> {\n                log.warning(\"Hello\");\n                log.warning(\"Something bad happened. error: def occured\");\n                log.warning(\"Bye\");\n            });\n\n            var requests = receiver.getRequests();\n            assertEquals(1, requests.size(), requests.toString());\n            var request = requests.get(0).asObject();\n            assertEquals(\"test\", request.get(\"username\").asString());\n            assertTrue(request.get(\"text\").asString().contains(\"Hello\"), request.get(\"text\").asString());\n            assertTrue(request.get(\"text\").asString().contains(\"Bye\"), request.get(\"text\").asString());\n\n            assertEquals(1, request.get(\"attachments\").asArray().size());\n\n            var attachment = request.get(\"attachments\").asArray().get(0);\n            assertTrue(attachment.get(\"title_link\").asString().contains(\"go.to/def\"));\n        }\n\n    }\n}\n"
  },
  {
    "path": "bots/cli/src/test/java/org/openjdk/skara/bots/cli/LoggingBot.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.cli;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.json.JSON;\n\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.concurrent.TimeoutException;\nimport java.util.function.Consumer;\nimport java.util.logging.*;\n\npublic class LoggingBot implements Bot, WorkItem {\n\n    private final Consumer<Logger> runnable;\n    private final Logger logger;\n\n    LoggingBot(Logger logger, Consumer<Logger> runnable) {\n        this.runnable = runnable;\n        this.logger = logger;\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        return List.of(this);\n    }\n\n\n    public static void runOnce(StreamHandler handler, Level handlerLevel, Consumer<Logger> runnable) {\n        var log = Logger.getLogger(\"org.openjdk.skara.bot\");\n        log.setLevel(Level.FINEST);\n        handler.setLevel(handlerLevel);\n        log.addHandler(handler);\n        var bot = new LoggingBot(log, runnable);\n\n        try {\n            var config = JSON.object().put(\"scratch\", JSON.object().put(\"path\", \"/tmp\"));\n            var runner = new BotRunner(BotRunnerConfiguration.parse(config), List.of(bot));\n            runner.runOnce(Duration.ofMinutes(10));\n        } catch (TimeoutException | ConfigurationError e) {\n            throw new RuntimeException(e);\n        }\n\n        log.removeHandler(handler);\n    }\n\n    public static void runOnce(StreamHandler handler, Consumer<Logger> runnable) {\n        runOnce(handler, Level.WARNING, runnable);\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        return false;\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        runnable.accept(logger);\n        return List.of();\n    }\n\n    @Override\n    public String name() {\n        return \"logging\";\n    }\n\n    @Override\n    public String botName() {\n        return name();\n    }\n\n    @Override\n    public String workItemName() {\n        return botName();\n    }\n\n    @Override\n    public String toString() {\n        return \"LoggingBot\";\n    }\n}\n"
  },
  {
    "path": "bots/cli/src/test/java/org/openjdk/skara/bots/cli/RestReceiver.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.cli;\n\nimport com.sun.net.httpserver.*;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.json.*;\n\nimport java.io.*;\nimport java.net.*;\nimport java.nio.charset.StandardCharsets;\nimport java.util.*;\n\nclass RestReceiver implements AutoCloseable {\n\n    private final HttpServer server;\n    private final List<JSONObject> requests;\n\n    class Handler implements HttpHandler {\n\n        @Override\n        public void handle(HttpExchange exchange) throws IOException {\n            var input = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);\n            requests.add(JSON.parse(input).asObject());\n\n            var response = \"{}\";\n            exchange.sendResponseHeaders(200, response.length());\n            OutputStream outputStream = exchange.getResponseBody();\n            outputStream.write(response.getBytes());\n            outputStream.close();\n        }\n    }\n\n    RestReceiver() throws IOException\n    {\n        requests = new ArrayList<>();\n        InetSocketAddress address = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0);\n        server = HttpServer.create(address, 0);\n        server.createContext(\"/test\", new Handler());\n        server.setExecutor(null);\n        server.start();\n    }\n\n    URI getEndpoint() {\n        return URIBuilder.base(\"http://\" + server.getAddress().getHostString() + \":\" +  server.getAddress().getPort() + \"/test\").build();\n    }\n\n    List<JSONObject> getRequests() {\n        return requests;\n    }\n\n    @Override\n    public void close() {\n        server.stop(0);\n    }\n}\n"
  },
  {
    "path": "bots/common/build.gradle",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.common'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.bots.common' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':vcs')\n    implementation project(':host')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':jbs')\n    implementation project(':json')\n    implementation project(':network')\n    implementation project(':jcheck')\n    implementation project(':census')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "bots/common/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\n/**\n * The bots.common module is meant for application level logic that needs to be\n * shared between multiple bots. This is needed for functionality that ties\n * together multiple different libraries that we don't want to create\n * dependencies between.\n */\nmodule org.openjdk.skara.bots.common {\n    requires org.openjdk.skara.vcs;\n    requires transitive org.openjdk.skara.forge;\n    requires org.openjdk.skara.network;\n    requires transitive org.openjdk.skara.jbs;\n    requires transitive org.openjdk.skara.jcheck;\n    requires java.logging;\n\n    exports org.openjdk.skara.bots.common;\n}"
  },
  {
    "path": "bots/common/src/main/java/org/openjdk/skara/bots/common/BotUtils.java",
    "content": "/*\n * Copyright (c) 2022, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.common;\n\nimport java.util.Set;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\n/**\n * This class contains utility methods used by more than one bot. These methods\n * can't reasonably be located in the various libraries as they combine\n * functionality and knowledge unique to bot applications. As this class grows,\n * it should be encouraged to split it up into more cohesive units.\n */\npublic class BotUtils {\n    private static final String lineSep = \"(?:\\\\n|\\\\r|\\\\r\\\\n|\\\\n\\\\r)\";\n    private static final Pattern issuesBlockPattern = Pattern.compile(lineSep + lineSep + \"###? Issues?((?:\" + lineSep + \"(?: \\\\* )?\\\\[.*)+)\", Pattern.MULTILINE);\n    private static final Pattern issuePattern = Pattern.compile(\"^(?: \\\\* )?\\\\[(\\\\S+)]\\\\(.*\\\\): (.*$)\", Pattern.MULTILINE);\n\n    public static String escape(String s) {\n        return s.replace(\"&\", \"&amp;\").replace(\"<\", \"&lt;\").replace(\">\", \"&gt;\")\n                .replace(\"@\", \"@<!---->\");\n    }\n\n    /**\n     * Parses issues from a pull request body and filters out JEP and CSR issues\n     *\n     * @param body The Pull Request Body\n     * @return Set of issue ids\n     */\n    public static Set<String> parseIssues(String body) {\n        var issuesBlockMatcher = issuesBlockPattern.matcher(body);\n        if (!issuesBlockMatcher.find()) {\n            return Set.of();\n        }\n        var issueMatcher = issuePattern.matcher(issuesBlockMatcher.group(1));\n        return issueMatcher.results()\n                .filter(mr -> !mr.group(2).endsWith(\" (**CSR**)\") && !mr.group(2).endsWith(\" (**CSR**) (Withdrawn)\") && !mr.group(2).endsWith(\" (**JEP**)\"))\n                .map(mo -> mo.group(1))\n                .collect(Collectors.toSet());\n    }\n\n    /**\n     * Parses issues from a pull request body.\n     *\n     * @param body The pull request body\n     * @return Set of issue ids\n     */\n    public static Set<String> parseAllIssues(String body) {\n        var issuesBlockMatcher = issuesBlockPattern.matcher(body);\n        if (!issuesBlockMatcher.find()) {\n            return Set.of();\n        }\n        var issueMatcher = issuePattern.matcher(issuesBlockMatcher.group(1));\n        return issueMatcher.results()\n                .map(mo -> mo.group(1))\n                .collect(Collectors.toSet());\n    }\n\n    public static String preprocessCommandLine(String line) {\n        return line.replaceFirst(\"/skara\\\\s+\", \"/\");\n    }\n}\n"
  },
  {
    "path": "bots/common/src/main/java/org/openjdk/skara/bots/common/CommandNameEnum.java",
    "content": "/*\n * Copyright (c) 2023, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.common;\n\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\n\n/**\n * Enum for Skara command names\n */\npublic enum CommandNameEnum {\n    help,\n    integrate,\n    sponsor,\n    contributor,\n    summary(true),\n    issue,\n    solves,\n    reviewers,\n    csr,\n    jep,\n    reviewer,\n    label,\n    cc,\n    clean,\n    open,\n    backport,\n    tag,\n    branch,\n    approval(true),\n    approve,\n    author,\n    touch,\n    keepalive,\n    template,\n    trailer;\n\n    private boolean isMultiLine = false;\n\n    CommandNameEnum() {\n    }\n\n    CommandNameEnum(boolean isMultiLine) {\n        this.isMultiLine = isMultiLine;\n    }\n\n    public boolean isMultiLine() {\n        return isMultiLine;\n    }\n\n    /* Utility method for returning command names separated by provided deliminator */\n    public static String commandNamesSepByDelim(String deliminator) {\n        return Stream.of(CommandNameEnum.values()).map(CommandNameEnum::name).collect(Collectors.joining(deliminator));\n    }\n}\n"
  },
  {
    "path": "bots/common/src/main/java/org/openjdk/skara/bots/common/PatternEnum.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.common;\n\nimport java.util.regex.Pattern;\n\nimport static java.util.regex.Pattern.DOTALL;\nimport static java.util.regex.Pattern.MULTILINE;\nimport static java.util.regex.Pattern.compile;\n\n/**\n * Enum for commonly used Regex patterns\n */\npublic enum PatternEnum {\n\n    EXECUTION_COMMAND_PATTERN(compile(\"^\\\\s*/([a-z]+)(?:\\\\s+|$)(.*)?\")),\n    COMMENT_PATTERN(compile(\"<!--.*?-->\", DOTALL | MULTILINE));\n\n    private final Pattern pattern;\n\n    PatternEnum(Pattern pattern) {\n        this.pattern = pattern;\n    }\n\n    public Pattern getPattern() {\n        return this.pattern;\n    }\n}\n"
  },
  {
    "path": "bots/common/src/main/java/org/openjdk/skara/bots/common/PullRequestConstants.java",
    "content": "/*\n * Copyright (c) 2023, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\npackage org.openjdk.skara.bots.common;\n\nimport java.util.regex.Pattern;\n\npublic class PullRequestConstants {\n    // MARKERS\n    public static final String PROGRESS_MARKER = \"<!-- Anything below this marker will be automatically updated, please do not edit manually! -->\";\n    public static final String CSR_NEEDED_MARKER = \"<!-- csr: 'needed' -->\";\n    public static final String CSR_UNNEEDED_MARKER = \"<!-- csr: 'unneeded' -->\";\n    public static final String JEP_MARKER = \"<!-- jep: '%s' '%s' '%s' -->\"; // <!-- jep: 'JEP-ID' 'ISSUE-ID' 'ISSUE-TITLE' -->\n    public static final String WEBREV_COMMENT_MARKER = \"<!-- mlbridge webrev comment -->\";\n    public static final String TEMPORARY_ISSUE_FAILURE_MARKER = \"<!-- temporary issue failure -->\";\n    public static final String READY_FOR_SPONSOR_MARKER = \"<!-- integration requested: '%s' -->\";\n    public static final String TOUCH_COMMAND_RESPONSE_MARKER = \"<!-- touch command response -->\";\n\n    // LABELS\n    public static final String CSR_LABEL = \"csr\";\n    public static final String JEP_LABEL = \"jep\";\n    public static final String APPROVAL_LABEL = \"approval\";\n\n    // PATTERNS\n    public static final Pattern JEP_MARKER_PATTERN = Pattern.compile(\"<!-- jep: '(.*?)' '(.*?)' '(.*?)' -->\");\n    public static final Pattern READY_FOR_SPONSOR_MARKER_PATTERN = Pattern.compile(\"<!-- integration requested: '(.*?)' -->\");\n}\n"
  },
  {
    "path": "bots/common/src/main/java/org/openjdk/skara/bots/common/SolvesTracker.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.common;\n\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.vcs.openjdk.Issue;\n\nimport java.nio.charset.StandardCharsets;\nimport java.util.*;\nimport java.util.regex.*;\n\npublic class SolvesTracker {\n    private static final String SOLVES_MARKER = \"<!-- solves: '%s' '%s' -->\";\n    private static final Pattern MARKER_PATTERN = Pattern.compile(\"<!-- solves: '(.*?)' '(.*?)' -->\");\n\n    public static String setSolvesMarker(Issue issue) {\n        var encodedDescription = Base64.getEncoder().encodeToString(issue.description().getBytes(StandardCharsets.UTF_8));\n        return String.format(SOLVES_MARKER, issue.shortId(), encodedDescription);\n    }\n\n    public static String removeSolvesMarker(Issue issue) {\n        return String.format(SOLVES_MARKER, issue.shortId(), \"\");\n    }\n\n    public static List<Issue> currentSolved(HostUser botUser, List<Comment> comments, String title) {\n        var solvesActions = comments.stream()\n                .filter(comment -> comment.author().equals(botUser))\n                .flatMap(comment -> comment.body().lines())\n                .map(MARKER_PATTERN::matcher)\n                .filter(Matcher::find)\n                .toList();\n        var current = new LinkedHashMap<String, Issue>();\n        var titleIssue = Issue.fromStringRelaxed(title);\n        for (var action : solvesActions) {\n            var key = action.group(1);\n            if (titleIssue.isPresent() && key.equals(titleIssue.get().shortId())) {\n                continue;\n            }\n            if (action.group(2).equals(\"\")) {\n                current.remove(key);\n            } else {\n                var decodedDescription = new String(Base64.getDecoder().decode(action.group(2)), StandardCharsets.UTF_8);\n                var issue = new Issue(key, decodedDescription);\n                current.put(key, issue);\n            }\n        }\n\n        return new ArrayList<>(current.values());\n    }\n\n    public static Optional<Comment> getLatestSolvesActionComment(HostUser botUser, List<Comment> comments, Issue issue) {\n        return comments.stream()\n                .filter(comment -> comment.author().equals(botUser))\n                .filter(comment -> comment.body().contains(\"<!-- solves: '\" + issue.shortId() + \"'\"))\n                .max(Comparator.comparing(Comment::createdAt));\n    }\n}\n"
  },
  {
    "path": "bots/forward/build.gradle",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.forward'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.bots.forward' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':ci')\n    implementation project(':host')\n    implementation project(':bot')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':census')\n    implementation project(':json')\n    implementation project(':vcs')\n    implementation project(':metrics')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "bots/forward/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.bots.forward {\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.vcs;\n    requires java.logging;\n\n    provides org.openjdk.skara.bot.BotFactory with org.openjdk.skara.bots.forward.ForwardBotFactory;\n}\n"
  },
  {
    "path": "bots/forward/src/main/java/org/openjdk/skara/bots/forward/ForwardBot.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.forward;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.net.URLEncoder;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.logging.Logger;\n\nclass ForwardBot implements Bot, WorkItem {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n\n    private final Path storage;\n\n    private final HostedRepository fromHostedRepo;\n    private final Branch fromBranch;\n\n    private final HostedRepository toHostedRepo;\n    private final Branch toBranch;\n\n    ForwardBot(Path storage, HostedRepository fromHostedRepo, Branch fromBranch,\n               HostedRepository toHostedRepo, Branch toBranch) {\n        this.storage = storage;\n        this.fromHostedRepo = fromHostedRepo;\n        this.fromBranch = fromBranch;\n        this.toHostedRepo = toHostedRepo;\n        this.toBranch = toBranch;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof ForwardBot otherBot)) {\n            return true;\n        }\n        return !toHostedRepo.name().equals(otherBot.toHostedRepo.name());\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        try {\n            var sanitizedUrl =\n                URLEncoder.encode(toHostedRepo.webUrl().toString(), StandardCharsets.UTF_8);\n            var toDir = storage.resolve(sanitizedUrl);\n            Repository toLocalRepo = null;\n            if (!Files.exists(toDir)) {\n                log.info(\"Cloning \" + toHostedRepo.name());\n                Files.createDirectories(toDir);\n                toLocalRepo = Repository.clone(toHostedRepo.authenticatedUrl(), toDir, true);\n            } else {\n                log.info(\"Found existing scratch directory for \" + toHostedRepo.name());\n                toLocalRepo = Repository.get(toDir).orElseThrow(() -> {\n                        return new RuntimeException(\"Repository in \" + toDir + \" has vanished\");\n                });\n            }\n\n            log.info(\"Fetching \" + fromHostedRepo.name() + \":\" + fromBranch.name() +\n                     \" to \" + toBranch.name());\n            var fetchHead = toLocalRepo.fetch(fromHostedRepo.authenticatedUrl(),\n                                              fromBranch.name() + \":\" + toBranch.name(),\n                                              false).orElseThrow();\n            log.info(\"Pushing \" + toBranch.name() + \" to \" + toHostedRepo.name());\n            toLocalRepo.push(fetchHead, toHostedRepo.authenticatedUrl(), toBranch.name(), false);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        return List.of();\n    }\n\n    @Override\n    public String toString() {\n        return \"ForwardBot@(\" + fromHostedRepo.name() + \":\" + fromBranch.name() +\n                           \"-> \" + toHostedRepo.name() + \":\" + toBranch.name() + \")\";\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        return List.of(this);\n    }\n\n    @Override\n    public String name() {\n        return ForwardBotFactory.NAME;\n    }\n\n    @Override\n    public String botName() {\n        return name();\n    }\n\n    @Override\n    public String workItemName() {\n        return botName();\n    }\n}\n"
  },
  {
    "path": "bots/forward/src/main/java/org/openjdk/skara/bots/forward/ForwardBotFactory.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.forward;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.vcs.Branch;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.nio.file.Files;\nimport java.util.*;\nimport java.util.logging.Logger;\n\npublic class ForwardBotFactory implements BotFactory {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n\n    static final String NAME = \"forward\";\n    @Override\n    public String name() {\n        return NAME;\n    }\n\n    @Override\n    public List<Bot> create(BotConfiguration configuration) {\n        var ret = new ArrayList<Bot>();\n        var storage = configuration.storageFolder();\n        try {\n            Files.createDirectories(storage);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        var specific = configuration.specific();\n\n        for (var repo : specific.get(\"repositories\").fields()) {\n            var repoName = repo.name();\n            var from = repo.value().get(\"from\").asString().split(\":\");\n            var fromRepo = configuration.repository(from[0]);\n            var fromBranch = new Branch(from[1]);\n\n            var to = repo.value().get(\"to\").asString().split(\":\");\n            var toRepo = configuration.repository(to[0]);\n            var toBranch = new Branch(to[1]);\n\n            var bot = new ForwardBot(storage, fromRepo, fromBranch, toRepo, toBranch);\n            log.info(\"Setting up forwarding from \" +\n                     fromRepo.name() + \":\" + fromBranch.name() +\n                     \"to \" + toRepo.name() + \":\" + toBranch.name());\n            ret.add(bot);\n        }\n\n        return ret;\n    }\n}\n"
  },
  {
    "path": "bots/forward/src/test/java/org/openjdk/skara/bots/forward/ForwardBotFactoryTest.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.forward;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.json.JWCC;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestBotFactory;\nimport org.openjdk.skara.test.TestHostedRepository;\n\nimport java.util.Comparator;\nimport java.util.Objects;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass ForwardBotFactoryTest {\n    @Test\n    public void testCreate() {\n        try (var tempFolder = new TemporaryDirectory()) {\n            String jsonString = \"\"\"\n                    {\n                      \"repositories\": {\n                        \"repo1\": {\n                          \"from\": \"from1:master\",\n                          \"to\": \"to1:master\"\n                        },\n                        \"repo2\": {\n                          \"from\": \"from2:dev\",\n                          \"to\": \"to2:test\"\n                        }\n                      }\n                    }\n                    \"\"\";\n            var jsonConfig = JWCC.parse(jsonString).asObject();\n\n            var testBotFactory = TestBotFactory.newBuilder()\n                    .addHostedRepository(\"from1\", new TestHostedRepository(\"from1\"))\n                    .addHostedRepository(\"from2\", new TestHostedRepository(\"from2\"))\n                    .addHostedRepository(\"to1\", new TestHostedRepository(\"to1\"))\n                    .addHostedRepository(\"to2\", new TestHostedRepository(\"to2\"))\n                    .storagePath(tempFolder.path().resolve(\"storage\"))\n                    .build();\n\n            var bots = testBotFactory.createBots(ForwardBotFactory.NAME, jsonConfig);\n            bots = bots.stream().sorted(Comparator.comparing(Objects::toString)).toList();\n            //A forwardBot for every configured repo\n            assertEquals(2, bots.size());\n\n            assertEquals(\"ForwardBot@(from1:master-> to1:master)\", bots.get(0).toString());\n            assertEquals(\"ForwardBot@(from2:dev-> to2:test)\", bots.get(1).toString());\n        }\n    }\n}"
  },
  {
    "path": "bots/forward/src/test/java/org/openjdk/skara/bots/forward/ForwardBotTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.forward;\n\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.*;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.StandardOpenOption;\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass ForwardBotTests {\n    private static final Branch master = new Branch(\"master\");\n\n    @Test\n    void mirrorMasterBranches(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var gitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var newFile = fromDir.resolve(\"this-file-cannot-exist.txt\");\n            Files.writeString(newFile, \"Hello world\\n\");\n            fromLocalRepo.add(newFile);\n            var newHash = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(newHash, fromCommits.get(0).hash());\n\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(0, toCommits.size());\n\n            var storage = temp.path().resolve(\"storage\");\n            var bot = new ForwardBot(storage, fromHostedRepo, master, toHostedRepo, master);\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(newHash, toCommits.get(0).hash());\n        }\n    }\n\n    @Test\n    void mirrorDifferentBranches(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var gitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var newFile = fromDir.resolve(\"this-file-cannot-exist.txt\");\n            Files.writeString(newFile, \"Hello world\\n\");\n            fromLocalRepo.add(newFile);\n            var newHash = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(newHash, fromCommits.get(0).hash());\n\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(0, toCommits.size());\n\n            var storage = temp.path().resolve(\"storage\");\n            var bot = new ForwardBot(storage, fromHostedRepo, master, toHostedRepo, new Branch(\"dev\"));\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(newHash, toCommits.get(0).hash());\n\n            var toBranches = toLocalRepo.branches();\n            assertEquals(1, toBranches.size());\n            assertEquals(\"dev\", toBranches.get(0).name());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/hgbridge/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.hgbridge'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.bots.hgbridge' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':bot')\n    implementation project(':ci')\n    implementation project(':vcs')\n    implementation project(':host')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':census')\n    implementation project(':process')\n    implementation project(':json')\n    implementation project(':network')\n    implementation project(':metrics')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "bots/hgbridge/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.bots.hgbridge {\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.host;\n    requires org.openjdk.skara.network;\n    requires org.openjdk.skara.census;\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.process;\n    requires java.logging;\n\n    provides org.openjdk.skara.bot.BotFactory with org.openjdk.skara.bots.hgbridge.JBridgeBotFactory;\n}\n"
  },
  {
    "path": "bots/hgbridge/src/main/java/org/openjdk/skara/bots/hgbridge/Exporter.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.hgbridge;\n\nimport org.openjdk.skara.process.Process;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.convert.*;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\nclass Exporter {\n    private final static Logger log = Logger.getLogger(\"org.openjdk.bots.hgbridge\");\n\n    private static void repack(Path gitRepo, boolean full) {\n        if (full) {\n            try (var p = Process.capture(\"git\", \"repack\", \"-a\", \"-d\", \"-f\", \"--depth=50\", \"--window=10000\")\n                                .workdir(gitRepo)\n                                .execute()) {\n                p.check();\n            }\n        } else {\n            try (var p = Process.capture(\"git\", \"repack\", \"--depth=50\", \"--window=100000\")\n                                .workdir(gitRepo)\n                                .execute()) {\n                p.check();\n            }\n        }\n    }\n\n    private static Set<Hash> unreachable(Path gitRepo) {\n        try (var p =  Process.capture(\"git\", \"fsck\", \"--unreachable\", \"--full\", \"--no-progress\")\n                             .workdir(gitRepo)\n                             .execute()) {\n            var lines = p.check().stdout();\n\n            return lines.stream()\n                        .filter(l -> l.startsWith(\"unreachable commit\"))\n                        .map(l -> l.split(\"\\\\s\")[2])\n                        .map(Hash::new)\n                        .collect(Collectors.toSet());\n        }\n    }\n\n    private static List<Mark> loadMarks(Path p) throws IOException {\n        if (Files.exists(p)) {\n            try (var lines = Files.lines(p)) {\n                return lines.map(line -> line.split(\",\"))\n                            .map(entry -> new Mark(Integer.parseInt(entry[0]), new Hash(entry[1]), new Hash(entry[2])))\n                            .collect(Collectors.toList());\n            }\n        } else {\n            return new ArrayList<>();\n        }\n    }\n\n    private static void saveMarks(List<Mark> marks, Path p) throws IOException {\n        var lines = marks.stream()\n                         .map(mark -> mark.key() + \",\" + mark.hg().hex() + \",\" + mark.git().hex())\n                         .collect(Collectors.toList());\n        Files.write(p, lines);\n    }\n\n    private static void clearDirectory(Path directory) {\n        try (var paths = Files.walk(directory)) {\n            paths.map(Path::toFile)\n                 .sorted(Comparator.reverseOrder())\n                 .forEach(File::delete);\n        } catch (IOException io) {\n            throw new RuntimeException(io);\n        }\n    }\n\n    private static Optional<Repository> tryExport(Converter converter, URI source, Path destination) throws IOException, InvalidLocalRepository {\n        var marksPath = destination.resolve(\"marks.txt\");\n        var sourcePath = destination.resolve(\"source\");\n        var importPath = destination.resolve(\"imported.git\");\n\n        boolean isInitialConversion = !Files.exists(marksPath);\n        if (isInitialConversion) {\n            // Ensure that there isn't anything else in the folder that may interfere\n            if (Files.exists(destination)) {\n                clearDirectory(destination);\n            } else {\n                Files.createDirectories(destination);\n            }\n            Repository.init(sourcePath, VCS.HG);\n            Repository.init(importPath, VCS.GIT);\n        }\n\n        var hgRepo = Repository.get(sourcePath).orElseThrow(() -> new InvalidLocalRepository(sourcePath));\n        var gitRepo = Repository.get(importPath).orElseThrow(() -> new InvalidLocalRepository(importPath));\n\n        var oldMarks = loadMarks(marksPath);\n        var allNewMarks = converter.pull(hgRepo, source, gitRepo, oldMarks);\n\n        var highestOldMark = oldMarks.stream().max(Mark::compareTo);\n        var highestNewMark = allNewMarks.stream().max(Mark::compareTo);\n        if (highestOldMark.isPresent() && highestNewMark.isPresent() && highestNewMark.get().key() <= highestOldMark.get().key()) {\n            log.fine(\"No new marks obtained - skipping further processing\");\n            return Optional.empty();\n        }\n\n        var unreachable = unreachable(gitRepo.root());\n        var newMarks = allNewMarks.stream()\n                .filter(mark -> !unreachable.contains(mark.git()))\n                .collect(Collectors.toList());\n\n        if (oldMarks.equals(newMarks)) {\n            log.fine(\"No new marks found after unreachable filtering - skipping further processing\");\n            return Optional.empty();\n        }\n\n        saveMarks(newMarks, marksPath);\n        repack(gitRepo.root(), isInitialConversion);\n\n        return Optional.of(gitRepo);\n    }\n\n    private static void syncFolder(Path source, Path destination) throws IOException {\n        if (!Files.isDirectory(source)) {\n            Files.createDirectories(source);\n        }\n        if (!Files.isDirectory(destination)) {\n            Files.createDirectories(destination);\n        }\n        try (var rsync = Process.capture(\"rsync\", \"--archive\", \"--delete\",\n                                         source.resolve(\".\").toString(),\n                                         destination.toString())\n                                .execute()) {\n            var result = rsync.await();\n            if (result.status() != 0) {\n                throw new IOException(\"Error during folder sync:\\n\" + result.stdout());\n            }\n        }\n    }\n\n    static Optional<Repository> export(Converter converter, URI source, Path destination, Path finalMarks) throws IOException {\n        final var successMarker = \"success.txt\";\n        final var lastKnownGood = destination.resolve(\"lkg\");\n        final var current = destination.resolve(\"current\");\n        Optional<Repository> ret;\n\n        // Restore state from previous last working export, if possible\n        if (Files.isDirectory(lastKnownGood)) {\n            if (!Files.exists(lastKnownGood.resolve(successMarker))) {\n                log.warning(\"Last known good folder does not contain a success marker - erasing\");\n                clearDirectory(lastKnownGood);\n            } else {\n                syncFolder(lastKnownGood, current);\n                Files.delete(current.resolve(successMarker));\n            }\n        } else {\n            if (Files.exists(destination)) {\n                log.info(\"No last known good export - erasing destination directory\");\n                clearDirectory(destination);\n            }\n        }\n\n        // Attempt export\n        try {\n            ret = tryExport(converter, source, current);\n        } catch (InvalidLocalRepository e) {\n            log.warning(\"Repository is corrupted, erasing destination directory\");\n            clearDirectory(destination);\n            try {\n                ret = tryExport(converter, source, current);\n            } catch (InvalidLocalRepository invalidLocalRepository) {\n                throw new IOException(\"Repository is corrupted even after a fresh export\");\n            }\n        }\n\n        // Exported new revisions successfully, update last known good copy\n        if (ret.isPresent()) {\n            Files.deleteIfExists(lastKnownGood.resolve(successMarker));\n            syncFolder(current, lastKnownGood);\n            lastKnownGood.resolve(successMarker).toFile().createNewFile();\n\n            // Update marks\n            var markSource = current.resolve(\"marks.txt\");\n            Files.copy(markSource, finalMarks, StandardCopyOption.REPLACE_EXISTING);\n        }\n\n        return ret;\n    }\n\n    static Optional<Repository> current(Path destination) throws IOException {\n        final var successMarker = \"success.txt\";\n        final var lastKnownGood = destination.resolve(\"lkg\");\n\n        if (!Files.exists(lastKnownGood.resolve(successMarker))) {\n            log.info(\"Last known good folder does not contain a success marker\");\n            return Optional.empty();\n        } else {\n            return Repository.get(lastKnownGood.resolve(\"imported.git\"));\n        }\n    }\n\n    static class InvalidLocalRepository extends Exception {\n        InvalidLocalRepository(Path path) {\n            super(path.toString());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/hgbridge/src/main/java/org/openjdk/skara/bots/hgbridge/ExporterConfig.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.hgbridge;\n\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.convert.*;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\nclass ExporterConfig {\n    private List<HostedRepository> destinations;\n    private URI source;\n    private HostedRepository configurationRepo;\n    private String configurationRef;\n    private HostedRepository marksRepo;\n    private String marksRef;\n    private String marksAuthorName;\n    private String marksAuthorEmail;\n    private List<String> replacementsFile;\n    private List<String> correctionsFile;\n    private List<String> lowercaseFile;\n    private List<String> punctuatedFile;\n    private List<String> authorsFile;\n    private List<String> contributorsFile;\n    private List<String> sponsorsFile;\n\n    void destinations(List<HostedRepository> destinations) {\n        this.destinations = destinations;\n    }\n\n    List<HostedRepository> destinations() {\n        return new ArrayList<>(destinations);\n    }\n\n    void source(URI source) {\n        this.source = source;\n    }\n\n    URI source() {\n        return source;\n    }\n\n    void configurationRepo(HostedRepository configurationRepo) {\n        this.configurationRepo = configurationRepo;\n    }\n\n    void configurationRef(String configurationRef) {\n        this.configurationRef = configurationRef;\n    }\n\n    void marksRepo(HostedRepository marksRepo) {\n        this.marksRepo = marksRepo;\n    }\n\n    HostedRepository marksRepo() {\n        return marksRepo;\n    }\n\n    void marksRef(String marksRef) {\n        this.marksRef = marksRef;\n    }\n\n    String marksRef() {\n        return marksRef;\n    }\n\n    void marksAuthorName(String marksAuthorName) {\n        this.marksAuthorName = marksAuthorName;\n    }\n\n    String marksAuthorName() {\n        return marksAuthorName;\n    }\n\n    void marksAuthorEmail(String marksAuthorEmail) {\n        this.marksAuthorEmail = marksAuthorEmail;\n    }\n\n    String marksAuthorEmail() {\n        return marksAuthorEmail;\n    }\n\n    void replacements(List<String> replacements) {\n        replacementsFile = replacements;\n    }\n\n    void corrections(List<String> corrections) {\n        correctionsFile = corrections;\n    }\n\n    void lowercase(List<String> lowercase) {\n        lowercaseFile = lowercase;\n    }\n\n    void punctuated(List<String> punctuated) {\n        punctuatedFile = punctuated;\n    }\n\n    void authors(List<String> authors) {\n        authorsFile = authors;\n    }\n\n    void contributors(List<String> contributors) {\n        contributorsFile = contributors;\n    }\n\n    void sponsors(List<String> sponsors) {\n        sponsorsFile = sponsors;\n    }\n\n    private interface FieldParser<T> {\n        T parse(JSONObject.Field value);\n    }\n\n    private <K, V> Map<K, V> parseMap(Path base, List<String> files, FieldParser<K> keyParser, FieldParser<V> valueParser) throws IOException {\n        var ret = new HashMap<K, V>();\n        for (var file : files) {\n            var jsonData = Files.readString(base.resolve(file));\n            var json = JSON.parse(jsonData);\n            for (var field : json.fields()) {\n                ret.put(keyParser.parse(field), valueParser.parse(field));\n            }\n        }\n        return ret;\n    }\n\n    private interface ValueParser<T> {\n        T parse(JSONValue value);\n    }\n\n    private <E> Set<E> parseCommits(Path base, List<String> files, ValueParser<E> valueParser) throws IOException {\n        var ret = new HashSet<E>();\n        for (var file : files) {\n            var jsonData = Files.readString(base.resolve(file));\n            var json = JSON.parse(jsonData);\n            for (var value : json.get(\"commits\").asArray()) {\n                ret.add(valueParser.parse(value));\n            }\n        }\n        return ret;\n    }\n\n    public Converter resolve(Path scratchPath) throws IOException {\n        var localRepo = Repository.materialize(scratchPath, configurationRepo.authenticatedUrl(),\n                                               \"+\" + configurationRef + \":hgbridge_config_\" + configurationRepo.name());\n\n        var replacements = parseMap(localRepo.root(), replacementsFile,\n                                    field -> new Hash(field.name()),\n                                    field -> field.value().stream()\n                                                  .map(JSONValue::asString).collect(Collectors.toList()));\n        var corrections = parseMap(localRepo.root(), correctionsFile,\n                                   field -> new Hash(field.name()),\n                                   field -> field.value().fields().stream()\n                                                 .collect(Collectors.toMap(JSONObject.Field::name, sub -> sub.value().asString())));\n        var lowercase = parseCommits(localRepo.root(), lowercaseFile, value -> new Hash(value.asString()));\n        var punctuated = parseCommits(localRepo.root(), punctuatedFile, value -> new Hash(value.asString()));\n        var authors = parseMap(localRepo.root(), authorsFile, JSONObject.Field::name, field -> field.value().asString());\n        var contributors = parseMap(localRepo.root(), contributorsFile, JSONObject.Field::name, field -> field.value().asString());\n        var sponsors = parseMap(localRepo.root(), sponsorsFile,\n                                JSONObject.Field::name,\n                                field -> field.value().stream()\n                                              .map(JSONValue::asString)\n                                              .collect(Collectors.toList()));\n\n        return new HgToGitConverter(replacements, corrections, lowercase, punctuated, authors, contributors, sponsors);\n    }\n\n    public String getConfigurationRef() {\n        return configurationRef;\n    }\n\n    public List<String> getReplacementsFile() {\n        return replacementsFile;\n    }\n\n    public List<String> getCorrectionsFile() {\n        return correctionsFile;\n    }\n\n    public List<String> getAuthorsFile() {\n        return authorsFile;\n    }\n\n    public List<String> getContributorsFile() {\n        return contributorsFile;\n    }\n\n    public List<String> getSponsorsFile() {\n        return sponsorsFile;\n    }\n}\n"
  },
  {
    "path": "bots/hgbridge/src/main/java/org/openjdk/skara/bots/hgbridge/JBridgeBot.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.hgbridge;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.vcs.Repository;\n\nimport java.io.*;\nimport java.net.URLEncoder;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.logging.Level;\nimport java.util.logging.Logger;\n\npublic class JBridgeBot implements Bot, WorkItem {\n    private final ExporterConfig exporterConfig;\n    private final Path storage;\n    private final Logger log = Logger.getLogger(\"org.openjdk.bots.hgbridge\");\n\n    JBridgeBot(ExporterConfig exporterConfig, Path storage) {\n        this.exporterConfig = exporterConfig;\n        this.storage = storage.resolve(URLEncoder.encode(exporterConfig.source().toString(), StandardCharsets.UTF_8));\n    }\n\n    @Override\n    public String toString() {\n        return \"JBridgeBot@\" + exporterConfig.source();\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        return List.of(this);\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (other instanceof JBridgeBot otherBridgeBot) {\n            return !exporterConfig.source().equals(otherBridgeBot.exporterConfig.source());\n        } else {\n            return true;\n        }\n    }\n\n    private void pushMarks(Path markSource, String destName, Path markScratchPath) throws IOException {\n        var marksRepo = Repository.materialize(markScratchPath, exporterConfig.marksRepo().authenticatedUrl(),\n                                               \"+\" + exporterConfig.marksRef() + \":hgbridge_marks\");\n\n        // We should never change existing marks\n        var markDest = markScratchPath.resolve(destName);\n        var updated = Files.readString(markSource);\n        if (Files.exists(markDest)) {\n            var existing = Files.readString(markDest);\n\n            if (!updated.startsWith(existing)) {\n                throw new RuntimeException(\"Update containing conflicting marks!\");\n            }\n            if (existing.equals(updated)) {\n                // Nothing new to push\n                return;\n            }\n        } else {\n            if (!Files.exists(markDest.getParent())) {\n                Files.createDirectories(markDest.getParent());\n            }\n        }\n\n        Files.writeString(markDest, updated);\n        marksRepo.add(markDest);\n        var hash = marksRepo.commit(\"Updated marks\", exporterConfig.marksAuthorName(), exporterConfig.marksAuthorEmail());\n        marksRepo.push(hash, exporterConfig.marksRepo().authenticatedUrl(), exporterConfig.marksRef());\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        log.fine(\"Running export for \" + exporterConfig.source().toString());\n\n        try {\n            var converter = exporterConfig.resolve(scratchPath.resolve(\"converter\"));\n            var marksFile = scratchPath.resolve(\"marks.txt\");\n            var exported = Exporter.export(converter, exporterConfig.source(), storage, marksFile);\n\n            // Push updated marks - other marks files may be updated concurrently, so try a few times\n            var retryCount = 0;\n            while (exported.isPresent()) {\n                try {\n                    pushMarks(marksFile,\n                              exporterConfig.source().getHost() + \"/\" + exporterConfig.source().getPath() + \"/marks.txt\",\n                              scratchPath.resolve(\"markspush\"));\n                    break;\n                } catch (IOException e) {\n                    retryCount++;\n                    if (retryCount > 10) {\n                        log.warning(\"Retry count exceeded for pushing marks\");\n                        throw new UncheckedIOException(e);\n                    }\n                }\n            }\n\n            IOException lastException = null;\n            for (var destination : exporterConfig.destinations()) {\n                var markerBase = destination.url().getHost() + \"/\" + destination.name();\n                var successfulPushMarker = storage.resolve(URLEncoder.encode(markerBase, StandardCharsets.UTF_8) + \".success.txt\");\n                if (exported.isPresent() || !successfulPushMarker.toFile().isFile()) {\n                    var repo = exported.orElse(Exporter.current(storage).orElseThrow());\n                    try {\n                        Files.deleteIfExists(successfulPushMarker);\n                        repo.pushAll(destination.authenticatedUrl());\n                        storage.resolve(successfulPushMarker).toFile().createNewFile();\n                    } catch (IOException e) {\n                        log.log(Level.SEVERE, \"Failed to push to \" + destination.authenticatedUrl(), e);\n                        lastException = e;\n                    }\n                } else {\n                    log.fine(\"No changes detected in \" + exporterConfig.source() + \" - skipping push to \" + destination.name());\n                }\n            }\n            if (lastException != null) {\n                throw new UncheckedIOException(lastException);\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        return List.of();\n    }\n\n    @Override\n    public String name() {\n        return JBridgeBotFactory.NAME;\n    }\n\n    @Override\n    public String botName() {\n        return name();\n    }\n\n    @Override\n    public String workItemName() {\n        return botName();\n    }\n\n    public ExporterConfig getExporterConfig() {\n        return exporterConfig;\n    }\n}\n"
  },
  {
    "path": "bots/hgbridge/src/main/java/org/openjdk/skara/bots/hgbridge/JBridgeBotFactory.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.hgbridge;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.json.*;\n\nimport java.util.*;\nimport java.util.stream.Collectors;\n\npublic class JBridgeBotFactory implements BotFactory {\n    private List<String> getSpecific(String field, JSONObject base, JSONObject specific) {\n        var ret = new ArrayList<String>();\n        if (base.contains(field)) {\n            ret.add(base.get(field).asString());\n        }\n        if (specific.contains(field)) {\n            ret.add(specific.get(field).asString());\n        }\n        return ret;\n    }\n\n    static final String NAME = \"hgbridge\";\n    @Override\n    public String name() {\n        return NAME;\n    }\n\n    @Override\n    public List<Bot> create(BotConfiguration configuration) {\n        var specific = configuration.specific();\n        var storage = configuration.storageFolder();\n\n        var marks = specific.get(\"marks\").asObject();\n        var marksRepo = configuration.repository(marks.get(\"repository\").asString());\n        var marksRef = marks.get(\"ref\").asString();\n        var marksName = marks.get(\"name\").asString();\n        var marksEmail = marks.get(\"email\").asString();\n\n        var converters = specific.get(\"converters\").stream()\n                                 .map(JSONValue::asObject)\n                                 .flatMap(base -> base.get(\"repositories\").stream()\n                                                      .map(JSONValue::asObject)\n                                                      .map(repo -> {\n                                                          var converterConfig = new ExporterConfig();\n                                                          // Base configuration options\n                                                          converterConfig.configurationRepo(configuration.repository(base.get(\"repository\").asString()));\n                                                          converterConfig.configurationRef(base.get(\"ref\").asString());\n\n                                                          // Mark storage configuration\n                                                          converterConfig.marksRepo(marksRepo);\n                                                          converterConfig.marksRef(marksRef);\n                                                          converterConfig.marksAuthorName(marksName);\n                                                          converterConfig.marksAuthorEmail(marksEmail);\n\n                                                          // Repository specific overrides\n                                                          converterConfig.replacements(getSpecific(\"replacements\", base, repo));\n                                                          converterConfig.corrections(getSpecific(\"corrections\", base, repo));\n                                                          converterConfig.lowercase(getSpecific(\"lowercase\", base, repo));\n                                                          converterConfig.punctuated(getSpecific(\"punctuated\", base, repo));\n                                                          converterConfig.authors(getSpecific(\"authors\", base, repo));\n                                                          converterConfig.contributors(getSpecific(\"contributors\", base, repo));\n                                                          converterConfig.sponsors(getSpecific(\"sponsors\", base, repo));\n\n                                                          // Repository specific only\n                                                          converterConfig.source(URIBuilder.base(repo.get(\"source\").asString()).build());\n                                                          converterConfig.destinations(repo.get(\"destinations\").stream()\n                                                                                           .map(JSONValue::asString)\n                                                                                           .map(configuration::repository)\n                                                                                           .collect(Collectors.toList()));\n                                                          return converterConfig;\n                                                      })\n                                 )\n                                 .collect(Collectors.toList());\n\n        return converters.stream()\n                         .map(config -> new JBridgeBot(config, storage))\n                         .collect(Collectors.toList());\n    }\n}\n"
  },
  {
    "path": "bots/hgbridge/src/test/java/org/openjdk/skara/bots/hgbridge/BridgeBotTests.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.hgbridge;\n\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.process.Process;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.Tag;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.convert.*;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.nio.file.*;\nimport java.time.ZonedDateTime;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n@TestInstance(TestInstance.Lifecycle.PER_CLASS)\nclass BridgeBotTests {\n\n    private static final String FIRST_CONVERTED_HASH = \"35b30f47db61035c479b18e0940fb6fdabc3944f\";\n\n    private List<String> runHgCommand(Repository repository, String... params) throws IOException {\n        List<String> finalParams = new ArrayList<>();\n        finalParams.add(\"hg\");\n        finalParams.addAll(List.of(\"--config\", \"extensions.strip=\"));\n\n        finalParams.addAll(List.of(params));\n        try (var p = Process.capture(finalParams.toArray(new String[0]))\n                            .workdir(repository.root().toString())\n                            .environ(\"HGRCPATH\", \"\")\n                            .environ(\"HGPLAIN\", \"\")\n                            .execute()) {\n            return p.check().stdout();\n        }\n    }\n\n    static class TestExporterConfig extends ExporterConfig {\n        private boolean badAuthors = false;\n\n        TestExporterConfig(URI source, HostedRepository destination, Path marksRepoPath) throws IOException {\n            this.source(source);\n            this.destinations(List.of(destination));\n\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n            var marksLocalRepo = TestableRepository.init(marksRepoPath.resolve(\"marks.git\"), VCS.GIT);\n\n            var initialFile = marksLocalRepo.root().resolve(\"init.txt\");\n            if (!Files.exists(initialFile)) {\n                Files.writeString(initialFile, \"Hello\");\n                marksLocalRepo.add(initialFile);\n                var hash = marksLocalRepo.commit(\"First\", \"duke\", \"duke@duke.duke\");\n                marksLocalRepo.checkout(hash, true); // Have to move away from the master branch to allow pushes\n            }\n\n            var marksHostedRepo = new TestHostedRepository(host, \"test\", marksLocalRepo);\n            this.marksRepo(marksHostedRepo);\n            this.marksRef(\"master\");\n            this.marksAuthorName(\"J. Duke\");\n            this.marksAuthorEmail(\"j@duke.duke\");\n        }\n\n        void setBadAuthors() {\n            this.badAuthors = true;\n        }\n\n        @Override\n        public Converter resolve(Path scratchPath) {\n            var replacements = new HashMap<Hash, List<String>>();\n            var corrections = new HashMap<Hash, Map<String, String>>();\n            var lowercase = new HashSet<Hash>();\n            var punctuated = new HashSet<Hash>();\n\n            var authors = Map.of(\"jjg\", \"JJG <jjg@openjdk.org>\",\n                                 \"duke\", \"Duke <duke@openjdk.org>\");\n            var contributors = new HashMap<String, String>();\n            var sponsors = new HashMap<String, List<String>>();\n\n            return new HgToGitConverter(replacements, corrections, lowercase, punctuated, badAuthors ? Map.of() : authors, contributors, sponsors);\n        }\n    }\n\n    private Set<String> getTagNames(Repository repo) throws IOException {\n        var tags = repo.tags().stream()\n                       .map(Tag::name)\n                       .collect(Collectors.toSet());\n        if (repo.defaultTag().isPresent()) {\n            tags.remove(repo.defaultTag().get().name());\n        }\n        return tags;\n    }\n\n    private Set<String> getCommitHashes(Repository repo) throws IOException {\n        try (var commits = repo.commits()) {\n            return commits.stream()\n                    .map(c -> c.hash().hex())\n                    .collect(Collectors.toSet());\n        }\n    }\n\n    private TemporaryDirectory sourceFolder;\n    private URI source;\n\n    @BeforeAll\n    void setup() throws IOException {\n        // Export the beginning of the jtreg repository\n        sourceFolder = new TemporaryDirectory();\n        try {\n            var localRepo = Repository.materialize(sourceFolder.path(), URIBuilder.base(\"https://hg.openjdk.org/code-tools/jtreg\").build(), \"default\");\n            runHgCommand(localRepo, \"strip\", \"-r\", \"b2511c725d81\");\n\n            // Create a lockfile in the mercurial repo, as it will overwrite the existing lock in the remote git repo\n            runHgCommand(localRepo, \"update\", \"null\");\n            runHgCommand(localRepo, \"branch\", \"testlock\");\n            var lockFile = localRepo.root().resolve(\"lock.txt\");\n            Files.writeString(lockFile, ZonedDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME));\n            localRepo.add(lockFile);\n            localRepo.commit(\"Lock\", \"duke\", \"Duke <duke@openjdk.org>\");\n        } catch (IOException e) {\n            Assumptions.assumeTrue(false, \"Failed to connect to hg.openjdk.org - skipping tests\");\n        }\n        this.source = sourceFolder.path().toUri();\n    }\n\n    @AfterAll\n    void teardown() {\n        sourceFolder.close();\n    }\n\n    @Test\n    void bridgeTest(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var hgFolder = new TemporaryDirectory();\n             var gitFolder = new TemporaryDirectory();\n             var storageFolder = new TemporaryDirectory();\n             var storageFolder2 = new TemporaryDirectory();\n             var marksFolder = new TemporaryDirectory()) {\n            // Export a partial version of a hg repository\n            var localHgRepo = Repository.materialize(hgFolder.path(), source, \"default\");\n            localHgRepo.fetch(source, \"testlock\").orElseThrow();\n            var destinationRepo = credentials.getHostedRepository();\n            var config = new TestExporterConfig(localHgRepo.root().toUri(), destinationRepo, marksFolder.path());\n            var bridge = new JBridgeBot(config, storageFolder.path());\n\n            runHgCommand(localHgRepo, \"strip\", \"-r\", \"bd7a3ed1210f\");\n            TestBotRunner.runPeriodicItems(bridge);\n\n            var localGitRepo = Repository.materialize(gitFolder.path(), destinationRepo.authenticatedUrl(), \"master\");\n\n            // Only a subset of known tags should be present\n            var localGitTags = getTagNames(localGitRepo);\n            assertEquals(getTagNames(localHgRepo), localGitTags);\n            assertTrue(localGitTags.contains(\"jtreg4.1-b02\"));\n            assertFalse(localGitTags.contains(\"jtreg4.1-b05\"));\n\n            // Import more revisions into the local hg repository and export again\n            localHgRepo.fetch(source, \"default\").orElseThrow();\n            TestBotRunner.runPeriodicItems(bridge);\n\n            // There should now be more tags present\n            Repository.materialize(gitFolder.path(), destinationRepo.authenticatedUrl(), \"master\");\n            localGitTags = getTagNames(localGitRepo);\n            assertEquals(getTagNames(localHgRepo), localGitTags);\n            assertTrue(localGitTags.contains(\"jtreg4.1-b02\"));\n            assertTrue(localGitTags.contains(\"jtreg4.1-b05\"));\n\n            // Export it again with different storage to force an export from scratch\n            bridge = new JBridgeBot(config, storageFolder2.path());\n            TestBotRunner.runPeriodicItems(bridge);\n            Repository.materialize(gitFolder.path(), destinationRepo.authenticatedUrl(), \"master\");\n            var newLocalGitTags = getTagNames(localGitRepo);\n            assertEquals(localGitTags, newLocalGitTags);\n\n            // Export it once more when nothing has changed\n            TestBotRunner.runPeriodicItems(bridge);\n            Repository.materialize(gitFolder.path(), destinationRepo.authenticatedUrl(), \"master\");\n            newLocalGitTags = getTagNames(localGitRepo);\n            assertEquals(localGitTags, newLocalGitTags);\n        }\n    }\n\n    @Test\n    void bridgeCorruptedStorageHg(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var storageFolder = new TemporaryDirectory();\n             var gitFolder = new TemporaryDirectory();\n             var marksFolder = new TemporaryDirectory()) {\n            var destinationRepo = credentials.getHostedRepository();\n\n            // Export an hg repository as is\n            var config = new TestExporterConfig(source, destinationRepo, marksFolder.path());\n            var bridge = new JBridgeBot(config, storageFolder.path());\n            TestBotRunner.runPeriodicItems(bridge);\n\n            // Materialize it and ensure that it contains a known commit\n            var localGitRepo = Repository.materialize(gitFolder.path(), destinationRepo.authenticatedUrl(), \"master\");\n            var localGitCommits = getCommitHashes(localGitRepo);\n            assertTrue(localGitCommits.contains(FIRST_CONVERTED_HASH));\n\n            // Now corrupt the .hg folder in the permanent storage\n            try (var paths = Files.walk(storageFolder.path())) {\n                paths.filter(p -> p.toString().contains(\"/.hg/\"))\n                     .filter(p -> p.toFile().isFile())\n                     .forEach(p -> {\n                         try {\n                             Files.delete(p);\n                         } catch (IOException e) {\n                             throw new UncheckedIOException(e);\n                         }\n                     });\n            }\n\n            // Now export it again - should still be intact\n            TestBotRunner.runPeriodicItems(bridge);\n            Repository.materialize(gitFolder.path(), destinationRepo.authenticatedUrl(), \"master\");\n            localGitCommits = getCommitHashes(localGitRepo);\n            assertTrue(localGitCommits.contains(FIRST_CONVERTED_HASH));\n        }\n    }\n\n    @Test\n    void bridgeExportScriptFailure(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var storageFolder = new TemporaryDirectory();\n             var storageFolder2 = new TemporaryDirectory();\n             var gitFolder = new TemporaryDirectory();\n             var marksFolder = new TemporaryDirectory()) {\n            var destinationRepo = credentials.getHostedRepository();\n\n            // Export an hg repository but with an empty authors list\n            var config = new TestExporterConfig(source, destinationRepo, marksFolder.path());\n            config.setBadAuthors();\n            var badBridge = new JBridgeBot(config, storageFolder.path());\n            assertThrows(RuntimeException.class, () -> TestBotRunner.runPeriodicItems(badBridge));\n\n            // Now once again with a correct configuration\n            config = new TestExporterConfig(source, destinationRepo, marksFolder.path());\n            var goodBridge = new JBridgeBot(config, storageFolder2.path());\n            TestBotRunner.runPeriodicItems(goodBridge);\n\n            // Verify that it now contains a known commit\n            var localGitRepo = Repository.materialize(gitFolder.path(), destinationRepo.authenticatedUrl(), \"master\");\n            var localGitCommits = getCommitHashes(localGitRepo);\n            assertTrue(localGitCommits.contains(FIRST_CONVERTED_HASH));\n        }\n    }\n\n    @Test\n    void bridgeReuseMarks(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var storageFolder = new TemporaryDirectory();\n             var gitFolder = new TemporaryDirectory();\n             var gitFolder2 = new TemporaryDirectory();\n             var gitFolder3 = new TemporaryDirectory();\n             var gitFolder4 = new TemporaryDirectory();\n             var marksFolder = new TemporaryDirectory()) {\n            var destinationRepo = credentials.getHostedRepository();\n            var config = new TestExporterConfig(source, destinationRepo, marksFolder.path());\n\n            // Export an hg repository as is\n            var bridge = new JBridgeBot(config, storageFolder.path());\n            TestBotRunner.runPeriodicItems(bridge);\n\n            // Materialize it and ensure that it contains a known commit\n            var localGitRepo = Repository.materialize(gitFolder.path(), destinationRepo.authenticatedUrl(), \"master\");\n            var localGitCommits = getCommitHashes(localGitRepo);\n            assertTrue(localGitCommits.contains(FIRST_CONVERTED_HASH));\n\n            // Push something else to overwrite it (but retain the lock)\n            var localRepo = CheckableRepository.init(gitFolder2.path(), destinationRepo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(destinationRepo.authenticatedUrl());\n\n            // Materialize it again and ensure that the known commit is now gone\n            localGitRepo = Repository.materialize(gitFolder3.path(), destinationRepo.authenticatedUrl(), \"master\");\n            localGitCommits = getCommitHashes(localGitRepo);\n            assertFalse(localGitCommits.contains(FIRST_CONVERTED_HASH));\n\n            // Now run the exporter again - nothing should happen\n            TestBotRunner.runPeriodicItems(bridge);\n\n            // Materialize it yet again and ensure that the known commit is still gone\n            localGitRepo = Repository.materialize(gitFolder4.path(), destinationRepo.authenticatedUrl(), \"master\");\n            localGitCommits = getCommitHashes(localGitRepo);\n            assertFalse(localGitCommits.contains(FIRST_CONVERTED_HASH));\n        }\n    }\n\n    @Test\n    void retryFailedPush(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var storageFolder = new TemporaryDirectory();\n             var gitFolder = new TemporaryDirectory();\n             var gitFolder2 = new TemporaryDirectory();\n             var gitFolder3 = new TemporaryDirectory();\n             var gitFolder4 = new TemporaryDirectory();\n             var gitFolder5 = new TemporaryDirectory();\n             var marksFolder = new TemporaryDirectory()) {\n            var destinationRepo = credentials.getHostedRepository();\n            var config = new TestExporterConfig(source, destinationRepo, marksFolder.path());\n\n            // Export an hg repository as is\n            var bridge = new JBridgeBot(config, storageFolder.path());\n            TestBotRunner.runPeriodicItems(bridge);\n\n            // Materialize it and ensure that it contains a known commit\n            var localGitRepo = Repository.materialize(gitFolder.path(), destinationRepo.authenticatedUrl(), \"master\");\n            var localGitCommits = getCommitHashes(localGitRepo);\n            assertTrue(localGitCommits.contains(FIRST_CONVERTED_HASH));\n\n            // Push something else to overwrite it\n            var localRepo = CheckableRepository.init(gitFolder2.path(), destinationRepo.repositoryType());\n            localRepo.pushAll(destinationRepo.authenticatedUrl());\n\n            // Materialize it again and ensure that the known commit is now gone\n            localGitRepo = Repository.materialize(gitFolder3.path(), destinationRepo.authenticatedUrl(), \"master\");\n            localGitCommits = getCommitHashes(localGitRepo);\n            assertFalse(localGitCommits.contains(FIRST_CONVERTED_HASH));\n\n            // Now run the exporter again - nothing should happen\n            TestBotRunner.runPeriodicItems(bridge);\n\n            // Materialize it yet again and ensure that the known commit is still gone\n            localGitRepo = Repository.materialize(gitFolder4.path(), destinationRepo.authenticatedUrl(), \"master\");\n            localGitCommits = getCommitHashes(localGitRepo);\n            assertFalse(localGitCommits.contains(FIRST_CONVERTED_HASH));\n\n            // Remove the successful push markers\n            try (var paths = Files.walk(storageFolder.path())) {\n                paths.filter(p -> p.toString().contains(\".success.txt\"))\n                     .filter(p -> p.toFile().isFile())\n                     .forEach(p -> {\n                         try {\n                             Files.delete(p);\n                         } catch (IOException e) {\n                             throw new UncheckedIOException(e);\n                         }\n                     });\n            }\n\n            // Now run the exporter again - it should do the push again\n            TestBotRunner.runPeriodicItems(bridge);\n\n            // Materialize it and ensure that the known commit is back\n            localGitRepo = Repository.materialize(gitFolder5.path(), destinationRepo.authenticatedUrl(), \"master\");\n            localGitCommits = getCommitHashes(localGitRepo);\n            assertTrue(localGitCommits.contains(FIRST_CONVERTED_HASH));\n        }\n    }\n\n    @Test\n    void filterUnreachable(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var hgFolder = new TemporaryDirectory();\n             var storageFolder = new TemporaryDirectory();\n             var marksFolder = new TemporaryDirectory()) {\n            // Export a hg repository with unreachable commits\n            var localHgRepo = Repository.materialize(hgFolder.path(), source, \"default\");\n            localHgRepo.fetch(source, \"testlock\").orElseThrow();\n            var destinationRepo = credentials.getHostedRepository();\n            var config = new TestExporterConfig(localHgRepo.root().toUri(), destinationRepo, marksFolder.path());\n            var bridge = new JBridgeBot(config, storageFolder.path());\n\n            runHgCommand(localHgRepo, \"update\", \"-r\", \"5\");\n            var other = localHgRepo.root().resolve(\"other.txt\");\n            Files.writeString(other, \"Hello\");\n            localHgRepo.add(other);\n            localHgRepo.commit(\"Another head\", \"duke\", \"\");\n            runHgCommand(localHgRepo, \"commit\", \"--close-branch\", \"--user=duke\", \"-m\", \"closing head\");\n\n            // Do an initial conversion, it will drop the closed head\n            TestBotRunner.runPeriodicItems(bridge);\n\n            // The second conversion should not encounter unreachable commits in the marks file\n            TestBotRunner.runPeriodicItems(bridge);\n        }\n    }\n\n    @Test\n    void changedMarks(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var hgFolder = new TemporaryDirectory();\n             var storageFolder = new TemporaryDirectory();\n             var storageFolder2 = new TemporaryDirectory();\n             var marksFolder = new TemporaryDirectory()) {\n            // Export a hg repository\n            var localHgRepo = Repository.materialize(hgFolder.path(), source, \"default\");\n            localHgRepo.fetch(source, \"testlock\").orElseThrow();\n            var destinationRepo = credentials.getHostedRepository();\n            var config = new TestExporterConfig(localHgRepo.root().toUri(), destinationRepo, marksFolder.path());\n            var bridge = new JBridgeBot(config, storageFolder.path());\n\n            runHgCommand(localHgRepo, \"update\", \"-r\", \"5\");\n            var other = localHgRepo.root().resolve(\"other.txt\");\n            Files.writeString(other, \"Hello\");\n            localHgRepo.add(other);\n            localHgRepo.commit(\"First\", \"duke\", \"\");\n\n            // Do an initial conversion\n            TestBotRunner.runPeriodicItems(bridge);\n\n            // Now roll back and commit something else\n            runHgCommand(localHgRepo, \"update\", \"-r\", \"5\");\n            Files.writeString(other, \"There\");\n            localHgRepo.add(other);\n            localHgRepo.commit(\"Second\", \"duke\", \"\");\n\n            // The second conversion (with fresh storage) should detect that marks have changed\n            var newBridge = new JBridgeBot(config, storageFolder2.path());\n            assertThrows(RuntimeException.class, () -> TestBotRunner.runPeriodicItems(newBridge));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/hgbridge/src/test/java/org/openjdk/skara/bots/hgbridge/JBridgeBotFactoryTest.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.hgbridge;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.json.JWCC;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestBotFactory;\nimport org.openjdk.skara.test.TestHostedRepository;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass JBridgeBotFactoryTest {\n    @Test\n    public void testCreate() {\n        try (var tempFolder = new TemporaryDirectory()) {\n            String jsonString = \"\"\"\n                    {\n                      \"marks\": {\n                        \"repository\": \"marks\",\n                        \"ref\": \"master\",\n                        \"name\": \"test\",\n                        \"email\": \"test@test.org\"\n                      },\n                      \"converters\": [\n                        {\n                          \"repository\": \"converter\",\n                          \"ref\": \"master\",\n                          \"authors\": \"test_authors.json\",\n                          \"contributors\": \"test_contributors.json\",\n                          \"sponsors\": \"test_sponsors.json\",\n                          \"corrections\": \"test_corrections.json\",\n                          \"replacements\": \"test_replacements.json\",\n                          \"repositories\": [\n                            {\n                              \"source\": \"https://test.org/source1\",\n                              \"destinations\": \"https://test.org/des1\",\n                              \"replacements\": \"test_replacements_for_repo1.json\",\n                              \"corrections\": \"test_corrections_for_repo1.json\"\n                            },\n                            {\n                              \"source\": \"https://test.org/source2\",\n                              \"destinations\": \"https://test.org/des2\",\n                              \"sponsors\": \"test_sponsors_for_repo2.json\",\n                              \"authors\": \"test_authors_for_repo2.json\",\n                              \"replacements\": \"test_replacements_for_repo2.json\",\n                            }\n                          ]\n                        }\n                      ]\n                    }\n                    \"\"\";\n            var jsonConfig = JWCC.parse(jsonString).asObject();\n\n            var testBotFactory = TestBotFactory.newBuilder()\n                    .addHostedRepository(\"marks\", new TestHostedRepository(\"marks\"))\n                    .addHostedRepository(\"converter\", new TestHostedRepository(\"converter\"))\n                    .addHostedRepository(\"https://test.org/des1\", new TestHostedRepository(\"des1\"))\n                    .addHostedRepository(\"https://test.org/des2\", new TestHostedRepository(\"des2\"))\n                    .storagePath(tempFolder.path().resolve(\"storage\"))\n                    .build();\n\n            var bots = testBotFactory.createBots(JBridgeBotFactory.NAME, jsonConfig);\n            // A JBridgeBot for every configured repo\n            assertEquals(2, bots.size());\n\n            JBridgeBot jBridgeBot1 = (JBridgeBot) bots.get(0);\n            assertEquals(\"JBridgeBot@https://test.org/source1\", jBridgeBot1.toString());\n            var exporterConfig1 = jBridgeBot1.getExporterConfig();\n            assertEquals(\"marks\", exporterConfig1.marksRepo().name());\n            assertEquals(\"master\", exporterConfig1.marksRef());\n            assertEquals(\"test@test.org\", exporterConfig1.marksAuthorEmail());\n            assertEquals(\"test\", exporterConfig1.marksAuthorName());\n            assertEquals(\"des1\", exporterConfig1.destinations().get(0).name());\n            assertEquals(\"https://test.org/source1\", exporterConfig1.source().toString());\n            assertEquals(\"master\", exporterConfig1.getConfigurationRef());\n            assertEquals(\"test_authors.json\", exporterConfig1.getAuthorsFile().get(0));\n            assertEquals(\"test_contributors.json\", exporterConfig1.getContributorsFile().get(0));\n            assertEquals(\"test_sponsors.json\", exporterConfig1.getSponsorsFile().get(0));\n            assertEquals(\"test_corrections.json\", exporterConfig1.getCorrectionsFile().get(0));\n            assertEquals(\"test_corrections_for_repo1.json\", exporterConfig1.getCorrectionsFile().get(1));\n            assertEquals(\"test_replacements.json\", exporterConfig1.getReplacementsFile().get(0));\n            assertEquals(\"test_replacements_for_repo1.json\", exporterConfig1.getReplacementsFile().get(1));\n\n            JBridgeBot jBridgeBot2 = (JBridgeBot) bots.get(1);\n            assertEquals(\"JBridgeBot@https://test.org/source2\", jBridgeBot2.toString());\n            var exporterConfig2 = jBridgeBot2.getExporterConfig();\n            assertEquals(\"marks\", exporterConfig2.marksRepo().name());\n            assertEquals(\"master\", exporterConfig2.marksRef());\n            assertEquals(\"test@test.org\", exporterConfig2.marksAuthorEmail());\n            assertEquals(\"test\", exporterConfig2.marksAuthorName());\n            assertEquals(\"des2\", exporterConfig2.destinations().get(0).name());\n            assertEquals(\"https://test.org/source2\", exporterConfig2.source().toString());\n            assertEquals(\"master\", exporterConfig2.getConfigurationRef());\n            assertEquals(\"test_authors.json\", exporterConfig2.getAuthorsFile().get(0));\n            assertEquals(\"test_authors_for_repo2.json\", exporterConfig2.getAuthorsFile().get(1));\n            assertEquals(\"test_contributors.json\", exporterConfig2.getContributorsFile().get(0));\n            assertEquals(\"test_sponsors.json\", exporterConfig2.getSponsorsFile().get(0));\n            assertEquals(\"test_sponsors_for_repo2.json\", exporterConfig2.getSponsorsFile().get(1));\n            assertEquals(\"test_corrections.json\", exporterConfig2.getCorrectionsFile().get(0));\n            assertEquals(\"test_replacements.json\", exporterConfig2.getReplacementsFile().get(0));\n            assertEquals(\"test_replacements_for_repo2.json\", exporterConfig2.getReplacementsFile().get(1));\n        }\n    }\n}"
  },
  {
    "path": "bots/merge/build.gradle",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.merge'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.bots.merge' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':ci')\n    implementation project(':host')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':bot')\n    implementation project(':census')\n    implementation project(':json')\n    implementation project(':vcs')\n    implementation project(':jcheck')\n    implementation project(':metrics')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "bots/merge/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.bots.merge {\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.jcheck;\n    requires java.logging;\n\n    provides org.openjdk.skara.bot.BotFactory with org.openjdk.skara.bots.merge.MergeBotFactory;\n}\n"
  },
  {
    "path": "bots/merge/src/main/java/org/openjdk/skara/bots/merge/Clock.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.merge;\n\nimport java.time.ZonedDateTime;\n\ninterface Clock {\n    ZonedDateTime now();\n}\n"
  },
  {
    "path": "bots/merge/src/main/java/org/openjdk/skara/bots/merge/MergeBot.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.merge;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.jcheck.JCheckConfiguration;\n\nimport java.io.IOException;\nimport java.io.File;\nimport java.io.UncheckedIOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Path;\nimport java.nio.file.Files;\nimport java.net.URLEncoder;\nimport java.time.DayOfWeek;\nimport java.time.Month;\nimport java.time.temporal.WeekFields;\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.stream.Collectors;\nimport java.util.logging.Logger;\n\nclass MergeBot implements Bot, WorkItem {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n    private final Path storage;\n\n    private final HostedRepositoryPool pool;\n    private final HostedRepository target;\n    private final HostedRepository fork;\n    private final List<Spec> specs;\n\n    private final Clock clock;\n\n    private final Map<String, Set<Integer>> hourly = new HashMap<>();\n    private final Map<String, Set<Integer>> daily = new HashMap<>();\n    private final Map<String, Set<Integer>> weekly = new HashMap<>();\n    private final Map<String, Set<Month>> monthly = new HashMap<>();\n    private final Map<String, Set<Integer>> yearly = new HashMap<>();\n\n    MergeBot(Path storage, HostedRepository target, HostedRepository fork,\n             List<Spec> specs) {\n        this(storage, target, fork, specs, new Clock() {\n            public ZonedDateTime now() {\n                return ZonedDateTime.now();\n            }\n        });\n    }\n\n    MergeBot(Path storage, HostedRepository target, HostedRepository fork,\n             List<Spec> specs, Clock clock) {\n        this.storage = storage;\n        this.pool = new HostedRepositoryPool(storage.resolve(\"seeds\"));\n        this.target = target;\n        this.fork = fork;\n        this.specs = specs;\n        this.clock = clock;\n    }\n\n    final static class Spec {\n        final static class Frequency {\n            static enum Interval {\n                HOURLY,\n                DAILY,\n                WEEKLY,\n                MONTHLY,\n                YEARLY;\n\n                boolean isHourly() {\n                    return this.equals(HOURLY);\n                }\n\n                boolean isDaily() {\n                    return this.equals(DAILY);\n                }\n\n                boolean isWeekly() {\n                    return this.equals(WEEKLY);\n                }\n\n                boolean isMonthly() {\n                    return this.equals(MONTHLY);\n                }\n\n                boolean isYearly() {\n                    return this.equals(YEARLY);\n                }\n            }\n\n            private final Interval interval;\n            private final DayOfWeek weekday;\n            private final Month month;\n            private final int day;\n            private final int hour;\n            private final int minute;\n\n            private Frequency(Interval interval, DayOfWeek weekday, Month month, int day, int hour, int minute) {\n                this.interval = interval;\n                this.weekday = weekday;\n                this.month = month;\n                this.day = day;\n                this.hour = hour;\n                this.minute = minute;\n            }\n\n            static Frequency hourly(int minute) {\n                return new Frequency(Interval.HOURLY, null, null, -1, -1, minute);\n            }\n\n            static Frequency daily(int hour) {\n                return new Frequency(Interval.DAILY, null, null, -1, hour, -1);\n            }\n\n            static Frequency weekly(DayOfWeek weekday, int hour) {\n                return new Frequency(Interval.WEEKLY, weekday, null, -1, hour, -1);\n            }\n\n            static Frequency monthly(int day, int hour) {\n                return new Frequency(Interval.MONTHLY, null, null, day, hour, -1);\n            }\n\n            static Frequency yearly(Month month, int day, int hour) {\n                return new Frequency(Interval.YEARLY, null, month, day, hour, -1);\n            }\n\n            boolean isHourly() {\n                return interval.isHourly();\n            }\n\n            boolean isDaily() {\n                return interval.isDaily();\n            }\n\n            boolean isWeekly() {\n                return interval.isWeekly();\n            }\n\n            boolean isMonthly() {\n                return interval.isMonthly();\n            }\n\n            boolean isYearly() {\n                return interval.isYearly();\n            }\n\n            DayOfWeek weekday() {\n                return weekday;\n            }\n\n            Month month() {\n                return month;\n            }\n\n            int day() {\n                return day;\n            }\n\n            int hour() {\n                return hour;\n            }\n\n            int minute() {\n                return minute;\n            }\n        }\n\n        private final HostedRepository fromRepo;\n        private final Branch fromBranch;\n        private final Branch toBranch;\n        private final Frequency frequency;\n        private final String name;\n        private final List<String> dependencies;\n        private final List<HostedRepository> prerequisites;\n\n        Spec(HostedRepository fromRepo, Branch fromBranch, Branch toBranch) {\n            this(fromRepo, fromBranch, toBranch, null, null, List.of(), List.of());\n        }\n\n        Spec(HostedRepository fromRepo, Branch fromBranch, Branch toBranch, String name) {\n            this(fromRepo, fromBranch, toBranch, null, name, List.of(), List.of());\n        }\n\n        Spec(HostedRepository fromRepo, Branch fromBranch, Branch toBranch, Frequency frequency) {\n            this(fromRepo, fromBranch, toBranch, frequency, null, List.of(), List.of());\n        }\n\n        Spec(HostedRepository fromRepo,\n             Branch fromBranch,\n             Branch toBranch,\n             Frequency frequency,\n             String name,\n             List<String> dependencies,\n             List<HostedRepository> prerequisites) {\n            this.fromRepo = fromRepo;\n            this.fromBranch = fromBranch;\n            this.toBranch = toBranch;\n            this.frequency = frequency;\n            this.name = name;\n            this.dependencies = dependencies;\n            this.prerequisites = prerequisites;\n        }\n\n        HostedRepository fromRepo() {\n            return fromRepo;\n        }\n\n        Branch fromBranch() {\n            return fromBranch;\n        }\n\n        Branch toBranch() {\n            return toBranch;\n        }\n\n        Optional<Frequency> frequency() {\n            return Optional.ofNullable(frequency);\n        }\n\n        Optional<String> name() {\n            return Optional.ofNullable(name);\n        }\n\n        List<String> dependencies() {\n            return dependencies;\n        }\n\n        List<HostedRepository> prerequisites() {\n            return prerequisites;\n        }\n    }\n\n    private static void deleteDirectory(Path dir) throws IOException {\n        try (var paths = Files.walk(dir)) {\n            paths.map(Path::toFile)\n                 .sorted(Comparator.reverseOrder())\n                 .forEach(File::delete);\n        }\n    }\n\n    private Repository cloneAndSyncFork(Path to) throws IOException {\n        var repo = pool.materialize(fork, to);\n\n        // Sync personal fork\n        var remoteBranches = repo.remoteBranches(target.authenticatedUrl().toString());\n        for (var branch : remoteBranches) {\n            var fetchHead = repo.fetch(target.authenticatedUrl(), branch.hash().hex(), false).orElseThrow();\n            repo.push(fetchHead, fork.authenticatedUrl(), branch.name());\n        }\n\n        // Must fetch once to update refs/heads\n        repo.fetchAllRemotes(false);\n\n        return repo;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof MergeBot otherBot)) {\n            return true;\n        }\n        return !target.name().equals(otherBot.target.name());\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        try {\n            var sanitizedUrl =\n                URLEncoder.encode(fork.webUrl().toString(), StandardCharsets.UTF_8);\n            var dir = storage.resolve(sanitizedUrl);\n\n            var repo = cloneAndSyncFork(dir);\n\n            var prTarget = fork.forge().repository(target.name()).orElseThrow(() ->\n                    new IllegalStateException(\"Can't get well-known repository \" + target.name())\n            );\n            var prs = prTarget.openPullRequests();\n            var currentUser = prTarget.forge().currentUser();\n\n            var unmerged = new HashSet<String>();\n            for (var spec : specs) {\n                var toBranch = spec.toBranch();\n                var fromRepo = spec.fromRepo();\n                var fromBranch = spec.fromBranch();\n\n                var targetName = Path.of(target.name()).getFileName();\n                var fromName = Path.of(fromRepo.name()).getFileName();\n                var fromDesc = targetName.equals(fromName) ? fromBranch.name() : fromName + \":\" + fromBranch.name();\n\n                var shouldMerge = true;\n\n                // Check if merge conflict pull request is present\n                var title = \"Merge \" + fromDesc;\n                var marker = \"<!-- AUTOMATIC MERGE PR -->\";\n                for (var pr : prs) {\n                    if (pr.title().equals(title) &&\n                        pr.targetRef().equals(toBranch.name()) &&\n                        pr.body().startsWith(marker) &&\n                        currentUser.equals(pr.author())) {\n                        // Yes, this could be optimized do a merge \"this turn\", but it is much simpler\n                        // to just wait until the next time the bot runs\n                        shouldMerge = false;\n                    }\n                }\n\n                // Check if merge should happen at this time\n                if (spec.frequency().isPresent()) {\n                    var now = clock.now();\n                    var desc = toBranch.name() + \"->\" + fromRepo.name() + \":\" + fromBranch.name();\n                    var freq = spec.frequency().get();\n                    if (freq.isHourly()) {\n                        if (!hourly.containsKey(desc)) {\n                            hourly.put(desc, new HashSet<>());\n                        }\n                        var minute = now.getMinute();\n                        var hour = now.getHour();\n                        if (freq.minute() == minute && !hourly.get(desc).contains(hour)) {\n                            hourly.get(desc).add(hour);\n                        } else {\n                            shouldMerge = false;\n                        }\n                    } else if (freq.isDaily()) {\n                        if (!daily.containsKey(desc)) {\n                            daily.put(desc, new HashSet<>());\n                        }\n                        var hour = now.getHour();\n                        var day = now.getDayOfYear();\n                        if (freq.hour() == hour && !daily.get(desc).contains(day)) {\n                            daily.get(desc).add(day);\n                        } else {\n                            shouldMerge = false;\n                        }\n                    } else if (freq.isWeekly()) {\n                        if (!weekly.containsKey(desc)) {\n                            weekly.put(desc, new HashSet<>());\n                        }\n                        var weekOfYear = now.get(WeekFields.ISO.weekOfYear());\n                        var weekday = now.getDayOfWeek();\n                        var hour = now.getHour();\n                        if (freq.weekday().equals(weekday) &&\n                            freq.hour() == hour &&\n                            !weekly.get(desc).contains(weekOfYear)) {\n                            weekly.get(desc).add(weekOfYear);\n                        } else {\n                            shouldMerge = false;\n                        }\n                    } else if (freq.isMonthly()) {\n                        if (!monthly.containsKey(desc)) {\n                            monthly.put(desc, new HashSet<>());\n                        }\n                        var day = now.getDayOfMonth();\n                        var hour = now.getHour();\n                        var month = now.getMonth();\n                        if (freq.day() == day && freq.hour() == hour &&\n                            !monthly.get(desc).contains(month)) {\n                            monthly.get(desc).add(month);\n                        } else {\n                            shouldMerge = false;\n                        }\n                    } else if (freq.isYearly()) {\n                        if (!yearly.containsKey(desc)) {\n                            yearly.put(desc, new HashSet<>());\n                        }\n                        var month = now.getMonth();\n                        var day = now.getDayOfMonth();\n                        var hour = now.getHour();\n                        var year = now.getYear();\n                        if (freq.month().equals(month) &&\n                            freq.day() == day &&\n                            freq.hour() == hour &&\n                            !yearly.get(desc).contains(year)) {\n                            yearly.get(desc).add(year);\n                        } else {\n                            shouldMerge = false;\n                        }\n                    }\n                }\n\n                // Check if any prerequisite repository has a conflict pull request open\n                if (shouldMerge) {\n                    for (var prereq : spec.prerequisites()) {\n                        var openMergeConflictPRs = prereq.openPullRequests()\n                                                         .stream()\n                                                         .filter(pr -> pr.title().startsWith(\"Merge \"))\n                                                         .filter(pr -> pr.body().startsWith(marker))\n                                                         .map(PullRequest::id)\n                                                         .collect(Collectors.toList());\n                        if (!openMergeConflictPRs.isEmpty()) {\n                            log.info(\"Will not merge because the prerequisite \" + prereq.name() +\n                                     \" has open merge conflicts PRs: \" +\n                                     String.join(\", \", openMergeConflictPRs));\n                            shouldMerge = false;\n                        }\n                    }\n                }\n\n                // Check if any dependencies failed\n                if (shouldMerge) {\n                    if (spec.dependencies().stream().anyMatch(unmerged::contains)) {\n                        var failed = spec.dependencies()\n                                         .stream()\n                                         .filter(unmerged::contains)\n                                         .collect(Collectors.toList());\n                        log.info(\"Will not merge because the following dependencies did not merge successfully: \" +\n                                 String.join(\", \", failed));\n                        shouldMerge = false;\n                    }\n                }\n\n                if (!shouldMerge) {\n                    log.info(\"Will not merge \" + fromRepo.name() + \":\" + fromBranch.name() + \" to \" + toBranch.name());\n                    if (spec.name().isPresent()) {\n                        unmerged.add(spec.name().get());\n                    }\n                    continue;\n                }\n\n                // Checkout the branch to merge into\n                repo.checkout(toBranch, false);\n                var remoteBranch = new Branch(repo.upstreamFor(toBranch).orElseThrow(() ->\n                    new IllegalStateException(\"Could not get remote branch name for \" + toBranch.name())\n                ));\n                repo.merge(remoteBranch, Repository.FastForward.ONLY);\n                if (!repo.isClean()) {\n                    throw new RuntimeException(\"Local repository isn't clean after fast-forward merge - has the fork diverged unexpectedly?\");\n                }\n\n                log.info(\"Trying to merge \" + fromRepo.name() + \":\" + fromBranch.name() + \" to \" + toBranch.name());\n                log.info(\"Fetching \" + fromRepo.name() + \":\" + fromBranch.name());\n                var fetchHead = repo.fetch(fromRepo.authenticatedUrl(), fromBranch.name(), false).orElseThrow();\n                var head = repo.resolve(toBranch.name()).orElseThrow(() ->\n                        new IOException(\"Could not resolve branch \" + toBranch.name())\n                );\n                if (repo.contains(toBranch, fetchHead)) {\n                    log.info(\"Nothing to merge\");\n                    continue;\n                }\n\n                var isAncestor = repo.isAncestor(head, fetchHead);\n\n                log.info(\"Merging into \" + toBranch.name());\n                IOException error = null;\n                try {\n                    repo.merge(fetchHead);\n                } catch (IOException e) {\n                    error = e;\n                }\n\n                if (error == null) {\n                    log.info(\"Pushing successful merge\");\n                    if (!isAncestor) {\n                        repo.commit(\"Automatic merge of \" + fromDesc + \" into \" + toBranch,\n                                \"duke\", \"duke@openjdk.org\");\n                    }\n                    try {\n                        repo.push(toBranch, target.authenticatedUrl().toString(), false);\n                    } catch (IOException e) {\n                        // A failed push can result in the local and remote branch diverging,\n                        // re-create the repository from the remote.\n                        // No need to create a pull request, just retry the merge and the push\n                        // the next run.\n                        deleteDirectory(dir);\n                        repo = cloneAndSyncFork(dir);\n                    }\n                } else {\n                    if (spec.name().isPresent()) {\n                        unmerged.add(spec.name().get());\n                    }\n                    log.info(\"Got error: \" + error.getMessage());\n                    log.info(\"Aborting unsuccesful merge\");\n                    var status = repo.status();\n                    repo.abortMerge();\n\n                    var numBranchesInFork = repo.remoteBranches(fork.authenticatedUrl().toString()).size();\n                    var branchDesc = Integer.toString(numBranchesInFork + 1);\n                    repo.push(fetchHead, fork.authenticatedUrl(), branchDesc);\n\n                    log.info(\"Creating pull request to alert\");\n                    var mergeBase = repo.mergeBase(fetchHead, head);\n\n                    var message = new ArrayList<String>();\n                    message.add(marker);\n                    message.add(\"<!-- \" + fetchHead.hex() + \" -->\");\n\n                    var commits = repo.commitMetadata(mergeBase.hex() + \"..\" + fetchHead.hex(), true);\n                    var numCommits = commits.size();\n                    var are = numCommits > 1 ? \"are\" : \"is\";\n                    var s = numCommits > 1 ? \"s\" : \"\";\n\n                    message.add(\"Hi all,\");\n                    message.add(\"\");\n                    message.add(\"this is an _automatically_ generated pull request to notify you that there \" +\n                                are + \" \" + numCommits + \" commit\" + s + \" from the branch `\" + fromDesc + \"`\" +\n                                \"that can **not** be merged into the branch `\" + toBranch.name() + \"`:\");\n\n                    message.add(\"\");\n                    var unmergedFiles = status.stream().filter(entry -> entry.status().isUnmerged()).collect(Collectors.toList());\n                    if (unmergedFiles.size() <= 10) {\n                        var files = unmergedFiles.size() > 1 ? \"files\" : \"file\";\n                        message.add(\"The following \" + files + \" contains merge conflicts:\");\n                        message.add(\"\");\n                        for (var fileStatus : unmergedFiles) {\n                            message.add(\"- \" + fileStatus.source().path().orElseThrow());\n                        }\n                    } else {\n                        message.add(\"Over \" + unmergedFiles.size() + \" files contains merge conflicts.\");\n                    }\n                    message.add(\"\");\n\n                    var project = JCheckConfiguration.from(repo, head).map(conf -> conf.general().project());\n                    if (project.isPresent()) {\n                        message.add(\"All Committers in this [project](https://openjdk.org/census#\" + project.get() + \") \" +\n                                    \"have access to my [personal fork](\" + fork.nonTransformedWebUrl() + \") and can \" +\n                                    \"therefore help resolve these merge conflicts (you may want to coordinate \" +\n                                    \"who should do this).\");\n                    } else {\n                        message.add(\"All users with access to my [personal fork](\" + fork.nonTransformedWebUrl() + \") \" +\n                                    \"can help resolve these merge conflicts \" +\n                                    \"(you may want to coordinate who should do this).\");\n                    }\n                    message.add(\"The following paragraphs will give an example on how to solve these \" +\n                                \"merge conflicts and push the resulting merge commit to this pull request.\");\n                    message.add(\"The below commands should be run in a local clone of your \" +\n                                \"[personal fork](https://wiki.openjdk.org/display/skara#Skara-Personalforks) \" +\n                                \"of the [\" + target.name() + \"](\" + target.nonTransformedWebUrl() + \") repository.\");\n                    message.add(\"\");\n                    var localBranchName = \"openjdk-bot-\" + branchDesc;\n                    message.add(\"```bash\");\n                    message.add(\"# Ensure target branch is up to date\");\n                    message.add(\"$ git checkout \" + toBranch.name());\n                    message.add(\"$ git pull \" + target.nonTransformedWebUrl() + \".git \" + toBranch.name());\n                    message.add(\"\");\n                    message.add(\"# Fetch and checkout the branch for this pull request\");\n                    message.add(\"$ git fetch \" + fork.nonTransformedWebUrl() + \".git +\" + branchDesc + \":\" + localBranchName);\n                    message.add(\"$ git checkout \" + localBranchName);\n                    message.add(\"\");\n                    message.add(\"# Merge the target branch\");\n                    message.add(\"$ git merge \" + toBranch.name());\n                    message.add(\"```\");\n                    message.add(\"\");\n                    message.add(\"When you have resolved the conflicts resulting from the `git merge` command \" +\n                                \"above, run the following commands to create a merge commit:\");\n                    message.add(\"\");\n                    message.add(\"```bash\");\n                    message.add(\"$ git add paths/to/files/with/conflicts\");\n                    message.add(\"$ git commit -m 'Merge \" + fromDesc + \"'\");\n                    message.add(\"```\");\n                    message.add(\"\");\n                    message.add(\"\");\n                    message.add(\"When you have created the merge commit, run the following command to push the merge commit \" +\n                                \"to this pull request:\");\n                    message.add(\"\");\n                    message.add(\"```bash\");\n                    message.add(\"$ git push \" + fork.nonTransformedWebUrl() + \".git \" + localBranchName + \":\" + branchDesc);\n                    message.add(\"```\");\n                    message.add(\"\");\n                    message.add(\"_Note_: if you are using SSH to push commits to GitHub, then change the URL in the above `git push` command accordingly.\");\n                    message.add(\"\");\n                    message.add(\"Thanks,\");\n                    message.add(\"J. Duke\");\n                    message.add(\"\");\n                    message.add(\"/integrate auto\");\n\n                    var pr = fork.createPullRequest(prTarget,\n                            toBranch.name(),\n                            branchDesc,\n                            title,\n                            message);\n                    pr.addLabel(\"failed-auto-merge\");\n                }\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        return List.of();\n    }\n\n    @Override\n    public String toString() {\n        return \"MergeBot@(\" + target.name() + \")\";\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        return List.of(this);\n    }\n\n    @Override\n    public String name() {\n        return MergeBotFactory.NAME;\n    }\n\n    @Override\n    public String botName() {\n        return name();\n    }\n\n    @Override\n    public String workItemName() {\n        return botName();\n    }\n\n    public List<Spec> getSpecs() {\n        return specs;\n    }\n}\n"
  },
  {
    "path": "bots/merge/src/main/java/org/openjdk/skara/bots/merge/MergeBotFactory.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.merge;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.vcs.Branch;\n\nimport java.io.*;\nimport java.nio.file.Files;\nimport java.time.DayOfWeek;\nimport java.time.Month;\nimport java.util.*;\nimport java.util.stream.Collectors;\nimport java.util.logging.Logger;\n\npublic class MergeBotFactory implements BotFactory {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n\n    static final String NAME = \"merge\";\n    @Override\n    public String name() {\n        return NAME;\n    }\n\n    private static MergeBot.Spec.Frequency.Interval toInterval(String s) {\n        switch (s.toLowerCase()) {\n            case \"hourly\":\n                return MergeBot.Spec.Frequency.Interval.HOURLY;\n            case \"daily\":\n                return MergeBot.Spec.Frequency.Interval.DAILY;\n            case \"weekly\":\n                return MergeBot.Spec.Frequency.Interval.WEEKLY;\n            case \"monthly\":\n                return MergeBot.Spec.Frequency.Interval.MONTHLY;\n            case \"yearly\":\n                return MergeBot.Spec.Frequency.Interval.YEARLY;\n            default:\n                throw new IllegalArgumentException(\"Unknown interval: \" + s);\n        }\n    }\n\n    private static DayOfWeek toWeekday(String s) {\n        switch (s.toLowerCase()) {\n            case \"monday\":\n                return DayOfWeek.MONDAY;\n            case \"tuesday\":\n                return DayOfWeek.TUESDAY;\n            case \"wednesday\":\n                return DayOfWeek.WEDNESDAY;\n            case \"thursday\":\n                return DayOfWeek.THURSDAY;\n            case \"friday\":\n                return DayOfWeek.FRIDAY;\n            case \"saturday\":\n                return DayOfWeek.SATURDAY;\n            case \"sunday\":\n                return DayOfWeek.SUNDAY;\n            default:\n                throw new IllegalArgumentException(\"Unknown weekday: \" + s);\n        }\n    }\n\n    private static Month toMonth(String s) {\n        switch (s.toLowerCase()) {\n            case \"january\":\n                return Month.JANUARY;\n            case \"february\":\n                return Month.FEBRUARY;\n            case \"march\":\n                return Month.MARCH;\n            case \"april\":\n                return Month.APRIL;\n            case \"may\":\n                return Month.MAY;\n            case \"june\":\n                return Month.JUNE;\n            case \"july\":\n                return Month.JULY;\n            case \"august\":\n                return Month.AUGUST;\n            case \"september\":\n                return Month.SEPTEMBER;\n            case \"october\":\n                return Month.OCTOBER;\n            case \"november\":\n                return Month.NOVEMBER;\n            case \"december\":\n                return Month.DECEMBER;\n            default:\n                throw new IllegalArgumentException(\"Unknown month: \" + s);\n        }\n    }\n\n    private static int toDay(int i) {\n        if (i < 0 || i > 30) {\n            throw new IllegalArgumentException(\"Unknown day: \" + i);\n        }\n        return i;\n    }\n\n    private static int toHour(int i) {\n        if (i < 0 || i > 23) {\n            throw new IllegalArgumentException(\"Unknown hour: \" + i);\n        }\n        return i;\n    }\n\n    private static int toMinute(int i) {\n        if (i < 0 || i > 59) {\n            throw new IllegalArgumentException(\"Unknown minute: \" + i);\n        }\n        return i;\n    }\n\n    @Override\n    public List<Bot> create(BotConfiguration configuration) {\n        var storage = configuration.storageFolder();\n        try {\n            Files.createDirectories(storage);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        var specific = configuration.specific();\n\n        var bots = new ArrayList<Bot>();\n        for (var repo : specific.get(\"repositories\").asArray()) {\n            var targetRepo = configuration.repository(repo.get(\"target\").asString());\n            var forkRepo = configuration.repository(repo.get(\"fork\").asString());\n\n            var specs = new ArrayList<MergeBot.Spec>();\n            for (var spec : repo.get(\"spec\").asArray()) {\n                var from = spec.get(\"from\").asString().split(\":\");\n                var fromRepo = configuration.repository(from[0]);\n                var fromBranch = new Branch(from[1]);\n                var toBranch = new Branch(spec.get(\"to\").asString());\n\n                MergeBot.Spec.Frequency frequency = null;\n                if (spec.contains(\"frequency\")) {\n                    var freq = spec.get(\"frequency\").asObject();\n                    var interval = toInterval(freq.get(\"interval\").asString());\n                    if (interval.isHourly()) {\n                        var minute = toMinute(freq.get(\"minute\").asInt());\n                        frequency = MergeBot.Spec.Frequency.hourly(minute);\n                    } else if (interval.isDaily()) {\n                        var hour = toHour(freq.get(\"hour\").asInt());\n                        frequency = MergeBot.Spec.Frequency.daily(hour);\n                    } else if (interval.isWeekly()) {\n                        var weekday = toWeekday(freq.get(\"weekday\").asString());\n                        var hour = toHour(freq.get(\"hour\").asInt());\n                        frequency = MergeBot.Spec.Frequency.weekly(weekday, hour);\n                    } else if (interval.isMonthly()) {\n                        var day = toDay(freq.get(\"day\").asInt());\n                        var hour = toHour(freq.get(\"hour\").asInt());\n                        frequency = MergeBot.Spec.Frequency.monthly(day, hour);\n                    } else if (interval.isYearly()) {\n                        var month = toMonth(freq.get(\"month\").asString());\n                        var day = toDay(freq.get(\"day\").asInt());\n                        var hour = toHour(freq.get(\"hour\").asInt());\n                        frequency = MergeBot.Spec.Frequency.yearly(month, day, hour);\n                    } else {\n                        throw new IllegalStateException(\"Unexpected interval: \" + interval);\n                    }\n                }\n\n                var name = spec.getOrDefault(\"name\", JSON.of()).asString();\n                var dependencies = spec.getOrDefault(\"dependencies\", JSON.array())\n                                       .stream()\n                                       .map(e -> e.asString())\n                                       .collect(Collectors.toList());\n                var prerequisites = spec.getOrDefault(\"prerequisites\", JSON.array())\n                                        .stream()\n                                        .map(e -> e.asString())\n                                        .map(configuration::repository)\n                                        .collect(Collectors.toList());\n\n                specs.add(new MergeBot.Spec(fromRepo,\n                                            fromBranch,\n                                            toBranch,\n                                            frequency,\n                                            name,\n                                            dependencies,\n                                            prerequisites));\n            }\n\n            bots.add(new MergeBot(storage, targetRepo, forkRepo, specs));\n        }\n        return bots;\n    }\n}\n"
  },
  {
    "path": "bots/merge/src/test/java/org/openjdk/skara/bots/merge/MergeBotFactoryTest.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.merge;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.json.JWCC;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestBotFactory;\nimport org.openjdk.skara.test.TestHostedRepository;\n\nimport java.time.DayOfWeek;\nimport java.time.Month;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass MergeBotFactoryTest {\n    @Test\n    public void testCreate() {\n        try (var tempFolder = new TemporaryDirectory()) {\n            String jsonString = \"\"\"\n                    {\n                      \"repositories\": [\n                        {\n                          \"target\": \"target\",\n                          \"fork\": \"fork\",\n                          \"spec\": [\n                            {\n                              \"from\": \"from1:master\",\n                              \"to\": \"master\",\n                              \"frequency\": {\n                                \"interval\": \"weekly\",\n                                \"weekday\": \"monday\",\n                                \"hour\": 3\n                              }\n                            },\n                            {\n                              \"name\": \"spec2\",\n                              \"from\": \"from2:master\",\n                              \"to\": \"test\"\n                            },\n                            {\n                              \"from\": \"from3:master\",\n                              \"to\": \"master\",\n                              \"frequency\": {\n                                \"interval\": \"hourly\",\n                                \"minute\": 30\n                              }\n                            },\n                            {\n                              \"from\": \"from4:master\",\n                              \"to\": \"master\",\n                              \"frequency\": {\n                                \"interval\": \"daily\",\n                                \"hour\": 2\n                              }\n                            },\n                            {\n                              \"from\": \"from5:master\",\n                              \"to\": \"master\",\n                              \"frequency\": {\n                                \"interval\": \"monthly\",\n                                \"day\": 1,\n                                \"hour\": 2\n                              }\n                            },\n                            {\n                              \"from\": \"from6:master\",\n                              \"to\": \"master\",\n                              \"frequency\": {\n                                \"interval\": \"yearly\",\n                                \"month\": \"october\",\n                                \"day\": 15,\n                                \"hour\": 5\n                              }\n                            }\n                          ]\n                        }\n                      ]\n                    }\n                    \"\"\";\n            var jsonConfig = JWCC.parse(jsonString).asObject();\n\n            var testBotFactory = TestBotFactory.newBuilder()\n                    .addHostedRepository(\"target\", new TestHostedRepository(\"target\"))\n                    .addHostedRepository(\"fork\", new TestHostedRepository(\"fork\"))\n                    .addHostedRepository(\"from1\", new TestHostedRepository(\"from1\"))\n                    .addHostedRepository(\"from2\", new TestHostedRepository(\"from2\"))\n                    .addHostedRepository(\"from3\", new TestHostedRepository(\"from3\"))\n                    .addHostedRepository(\"from4\", new TestHostedRepository(\"from4\"))\n                    .addHostedRepository(\"from5\", new TestHostedRepository(\"from5\"))\n                    .addHostedRepository(\"from6\", new TestHostedRepository(\"from6\"))\n                    .storagePath(tempFolder.path().resolve(\"storage\"))\n                    .build();\n\n            var bots = testBotFactory.createBots(MergeBotFactory.NAME, jsonConfig);\n            assertEquals(1, bots.size());\n\n            MergeBot mergeBot = (MergeBot) bots.get(0);\n            assertEquals(\"MergeBot@(target)\", mergeBot.toString());\n\n            // Check the contents in the mergeBot\n            var specs = mergeBot.getSpecs();\n            MergeBot.Spec spec1 = specs.get(0);\n            MergeBot.Spec.Frequency frequency1 = spec1.frequency().get();\n            assertTrue(spec1.name().isEmpty());\n            assertTrue(frequency1.isWeekly());\n            assertEquals(DayOfWeek.MONDAY, frequency1.weekday());\n            assertEquals(3, frequency1.hour());\n\n            MergeBot.Spec spec2 = specs.get(1);\n            assertTrue(spec2.frequency().isEmpty());\n            assertTrue(spec2.name().isPresent());\n\n            MergeBot.Spec spec3 = specs.get(2);\n            MergeBot.Spec.Frequency frequency3 = spec3.frequency().get();\n            assertTrue(frequency3.isHourly());\n            assertEquals(30, frequency3.minute());\n\n            MergeBot.Spec spec4 = specs.get(3);\n            MergeBot.Spec.Frequency frequency4 = spec4.frequency().get();\n            assertTrue(frequency4.isDaily());\n            assertEquals(2, frequency4.hour());\n\n            MergeBot.Spec spec5 = specs.get(4);\n            MergeBot.Spec.Frequency frequency5 = spec5.frequency().get();\n            assertTrue(frequency5.isMonthly());\n            assertEquals(1, frequency5.day());\n            assertEquals(2, frequency5.hour());\n\n            MergeBot.Spec spec6 = specs.get(5);\n            MergeBot.Spec.Frequency frequency6 = spec6.frequency().get();\n            assertTrue(frequency6.isYearly());\n            assertEquals(Month.OCTOBER, frequency6.month());\n            assertEquals(15, frequency6.day());\n            assertEquals(5, frequency6.hour());\n        }\n    }\n}"
  },
  {
    "path": "bots/merge/src/test/java/org/openjdk/skara/bots/merge/MergeBotTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.merge;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.IOException;\nimport java.net.URLEncoder;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.*;\nimport java.time.*;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass MergeBotTests {\n    @Test\n    void mergeMasterBranch(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var toGitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(toGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var forkDir = temp.path().resolve(\"fork.git\");\n            var forkLocalRepo = TestableRepository.init(forkDir, VCS.GIT);\n            var forkGitConfig = forkDir.resolve(\".git\").resolve(\"config\");\n            Files.write(forkGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toFork = new TestHostedRepository(host, \"test-mirror-fork\", forkLocalRepo);\n\n            var now = ZonedDateTime.now();\n            var fromFileA = fromDir.resolve(\"a.txt\");\n            Files.writeString(fromFileA, \"Hello A\\n\");\n            fromLocalRepo.add(fromFileA);\n            var fromHashA = fromLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(fromHashA, fromCommits.get(0).hash());\n\n            var toFileA = toDir.resolve(\"a.txt\");\n            Files.writeString(toFileA, \"Hello A\\n\");\n            toLocalRepo.add(toFileA);\n            var toHashA = toLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(toHashA, toCommits.get(0).hash());\n            assertEquals(fromHashA, toHashA);\n\n            var fromFileB = fromDir.resolve(\"b.txt\");\n            Files.writeString(fromFileB, \"Hello B\\n\");\n            fromLocalRepo.add(fromFileB);\n            var fromHashB = fromLocalRepo.commit(\"Adding b.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var toFileC = toDir.resolve(\"c.txt\");\n            Files.writeString(toFileC, \"Hello C\\n\");\n            toLocalRepo.add(toFileC);\n            var toHashC = toLocalRepo.commit(\"Adding c.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var storage = temp.path().resolve(\"storage\");\n            var master = new Branch(\"master\");\n            var specs = List.of(new MergeBot.Spec(fromHostedRepo, master, master));\n            var bot = new MergeBot(storage, toHostedRepo, toFork, specs);\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n            var hashes = toCommits.stream().map(Commit::hash).collect(Collectors.toSet());\n            assertTrue(hashes.contains(toHashA));\n            assertTrue(hashes.contains(fromHashB));\n            assertTrue(hashes.contains(toHashC));\n\n            var known = Set.of(toHashA, fromHashB, toHashC);\n            var merge = toCommits.stream().filter(c -> !known.contains(c.hash())).findAny().get();\n            assertTrue(merge.isMerge());\n            assertEquals(List.of(\"Automatic merge of test:master into master\"), merge.message());\n            assertEquals(\"duke\", merge.author().name());\n            assertEquals(\"duke@openjdk.org\", merge.author().email());\n\n            assertEquals(0, toHostedRepo.openPullRequests().size());\n        }\n    }\n\n    @Test\n    void successfulDependency(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory(false)) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var toGitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(toGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var forkDir = temp.path().resolve(\"fork.git\");\n            var forkLocalRepo = TestableRepository.init(forkDir, VCS.GIT);\n            var forkGitConfig = forkDir.resolve(\".git\").resolve(\"config\");\n            Files.write(forkGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toFork = new TestHostedRepository(host, \"test-mirror-fork\", forkLocalRepo);\n\n            var now = ZonedDateTime.now();\n            var fromFileA = fromDir.resolve(\"a.txt\");\n            Files.writeString(fromFileA, \"Hello A\\n\");\n            fromLocalRepo.add(fromFileA);\n            var fromHashA = fromLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(fromHashA, fromCommits.get(0).hash());\n\n            var toFileA = toDir.resolve(\"a.txt\");\n            Files.writeString(toFileA, \"Hello A\\n\");\n            toLocalRepo.add(toFileA);\n            var toHashA = toLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(toHashA, toCommits.get(0).hash());\n            assertEquals(fromHashA, toHashA);\n            toLocalRepo.branch(toHashA, \"feature\");\n            assertEquals(2, toLocalRepo.branches().size());\n\n            var fromFileB = fromDir.resolve(\"b.txt\");\n            Files.writeString(fromFileB, \"Hello B\\n\");\n            fromLocalRepo.add(fromFileB);\n            var fromHashB = fromLocalRepo.commit(\"Adding b.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var featureBranch = fromLocalRepo.branch(fromHashB, \"feature\");\n            fromLocalRepo.checkout(featureBranch);\n            var fromFileD = fromDir.resolve(\"d.txt\");\n            Files.writeString(fromFileD, \"Hello D\\n\");\n            fromLocalRepo.add(fromFileD);\n            var fromHashD = fromLocalRepo.commit(\"Adding d.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var toFileC = toDir.resolve(\"c.txt\");\n            Files.writeString(toFileC, \"Hello C\\n\");\n            toLocalRepo.add(toFileC);\n            var toHashC = toLocalRepo.commit(\"Adding c.txt\", \"duke\", \"duke@openjdk.org\");\n\n            toLocalRepo.checkout(featureBranch);\n            var toFileE = toDir.resolve(\"e.txt\");\n            Files.writeString(toFileE, \"Hello E\\n\");\n            toLocalRepo.add(toFileE);\n            var toHashE = toLocalRepo.commit(\"Adding e.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var storage = temp.path().resolve(\"storage\");\n            var master = new Branch(\"master\");\n            var feature = new Branch(\"feature\");\n            var specs = List.of(new MergeBot.Spec(fromHostedRepo, master, master, null, \"master\", List.of(), List.of()),\n                                new MergeBot.Spec(fromHostedRepo, feature, feature, null, \"feature\", List.of(\"master\"), List.of()));\n            var bot = new MergeBot(storage, toHostedRepo, toFork, specs);\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(7, toCommits.size());\n            var hashes = toCommits.stream().map(Commit::hash).collect(Collectors.toSet());\n            assertTrue(hashes.contains(toHashA));\n            assertTrue(hashes.contains(fromHashB));\n            assertTrue(hashes.contains(toHashC));\n\n            var merges = toCommits.stream().filter(c -> c.isMerge()).collect(Collectors.toList());\n            assertEquals(2, merges.size());\n\n            assertTrue(merges.stream().anyMatch(c -> c.message().get(0).equals(\"Automatic merge of test:master into master\")));\n            assertTrue(merges.stream().anyMatch(c -> c.message().get(0).equals(\"Automatic merge of test:feature into feature\")));\n        }\n    }\n\n    @Test\n    void failedDependency(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory(false)) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var toGitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(toGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var forkDir = temp.path().resolve(\"fork.git\");\n            var forkLocalRepo = TestableRepository.init(forkDir, VCS.GIT);\n            var forkGitConfig = forkDir.resolve(\".git\").resolve(\"config\");\n            Files.write(forkGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toFork = new TestHostedRepository(host, \"test-mirror-fork\", forkLocalRepo);\n\n            var now = ZonedDateTime.now();\n            var fromFileA = fromDir.resolve(\"a.txt\");\n            Files.writeString(fromFileA, \"Hello A\\n\");\n            fromLocalRepo.add(fromFileA);\n            var fromHashA = fromLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(fromHashA, fromCommits.get(0).hash());\n\n            var toFileA = toDir.resolve(\"a.txt\");\n            Files.writeString(toFileA, \"Hello A\\n\");\n            toLocalRepo.add(toFileA);\n            var toHashA = toLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(toHashA, toCommits.get(0).hash());\n            assertEquals(fromHashA, toHashA);\n            toLocalRepo.branch(toHashA, \"feature\");\n            assertEquals(2, toLocalRepo.branches().size());\n\n            var fromFileB = fromDir.resolve(\"b.txt\");\n            Files.writeString(fromFileB, \"Hello B\\n\");\n            fromLocalRepo.add(fromFileB);\n            var fromHashB = fromLocalRepo.commit(\"Adding b.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var featureBranch = fromLocalRepo.branch(fromHashB, \"feature\");\n            fromLocalRepo.checkout(featureBranch);\n            var fromFileD = fromDir.resolve(\"d.txt\");\n            Files.writeString(fromFileD, \"Hello D\\n\");\n            fromLocalRepo.add(fromFileD);\n            var fromHashD = fromLocalRepo.commit(\"Adding d.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var toFileB = toDir.resolve(\"b.txt\");\n            Files.writeString(toFileB, \"Hello conflict\\n\");\n            toLocalRepo.add(toFileB);\n            var toHashB = toLocalRepo.commit(\"Adding b.txt\", \"duke\", \"duke@openjdk.org\");\n\n            toLocalRepo.checkout(featureBranch);\n            var toFileE = toDir.resolve(\"e.txt\");\n            Files.writeString(toFileE, \"Hello E\\n\");\n            toLocalRepo.add(toFileE);\n            var toHashE = toLocalRepo.commit(\"Adding e.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var toCommitsBeforeMerge = toLocalRepo.commits().asList();\n            assertEquals(3, toCommitsBeforeMerge.size());\n            assertEquals(toHashE, toCommitsBeforeMerge.get(0).hash());\n            assertEquals(toHashB, toCommitsBeforeMerge.get(1).hash());\n            assertEquals(toHashA, toCommitsBeforeMerge.get(2).hash());\n            assertEquals(toHashB, toLocalRepo.resolve(\"master\").get());\n            assertEquals(toHashE, toLocalRepo.resolve(\"feature\").get());\n\n            var storage = temp.path().resolve(\"storage\");\n            var master = new Branch(\"master\");\n            var feature = new Branch(\"feature\");\n            var specs = List.of(new MergeBot.Spec(fromHostedRepo, master, master, null, \"master\", List.of(), List.of()),\n                                new MergeBot.Spec(fromHostedRepo, feature, feature, null, \"feature\", List.of(\"master\"), List.of()));\n            var bot = new MergeBot(storage, toHostedRepo, toFork, specs);\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(toCommitsBeforeMerge.size(), toCommits.size());\n            assertEquals(toCommitsBeforeMerge.get(0).hash(), toCommits.get(0).hash());\n            assertEquals(toCommitsBeforeMerge.get(1).hash(), toCommits.get(1).hash());\n            assertEquals(toCommitsBeforeMerge.get(2).hash(), toCommits.get(2).hash());\n            assertEquals(toHashB, toLocalRepo.resolve(\"master\").get());\n            assertEquals(toHashE, toLocalRepo.resolve(\"feature\").get());\n        }\n    }\n\n    @Test\n    void failingMergeTest(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var toGitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(toGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var forkDir = temp.path().resolve(\"fork.git\");\n            var forkLocalRepo = TestableRepository.init(forkDir, VCS.GIT);\n            var forkGitConfig = forkDir.resolve(\".git\").resolve(\"config\");\n            Files.write(forkGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toFork = new TestHostedRepository(host, \"test-mirror-fork\", forkLocalRepo);\n\n            var now = ZonedDateTime.now();\n            var fromFileA = fromDir.resolve(\"a.txt\");\n            Files.writeString(fromFileA, \"Hello A\\n\");\n            fromLocalRepo.add(fromFileA);\n            var fromHashA = fromLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(fromHashA, fromCommits.get(0).hash());\n\n            var toFileA = toDir.resolve(\"a.txt\");\n            Files.writeString(toFileA, \"Hello A\\n\");\n            toLocalRepo.add(toFileA);\n            var toHashA = toLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(toHashA, toCommits.get(0).hash());\n            assertEquals(fromHashA, toHashA);\n\n            var fromFileB = fromDir.resolve(\"b.txt\");\n            Files.writeString(fromFileB, \"Hello B1\\n\");\n            fromLocalRepo.add(fromFileB);\n            var fromHashB = fromLocalRepo.commit(\"Adding b1.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var toFileB = toDir.resolve(\"b.txt\");\n            Files.writeString(toFileB, \"Hello B2\\n\");\n            toLocalRepo.add(toFileB);\n            var toHashB = toLocalRepo.commit(\"Adding b2.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var storage = temp.path().resolve(\"storage\");\n            var master = new Branch(\"master\");\n            var specs = List.of(new MergeBot.Spec(fromHostedRepo, master, master));\n            var bot = new MergeBot(storage, toHostedRepo, toFork, specs);\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(2, toCommits.size());\n            var toHashes = toCommits.stream().map(Commit::hash).collect(Collectors.toSet());\n            assertTrue(toHashes.contains(toHashA));\n            assertTrue(toHashes.contains(toHashB));\n\n            var pullRequests = toHostedRepo.openPullRequests();\n            assertEquals(1, pullRequests.size());\n            var pr = pullRequests.get(0);\n            assertEquals(\"Merge test:master\", pr.title());\n            assertTrue(pr.labelNames().contains(\"failed-auto-merge\"));\n        }\n    }\n\n    @Test\n    void failingPrerequisite(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var toGitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(toGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var forkDir = temp.path().resolve(\"fork.git\");\n            var forkLocalRepo = TestableRepository.init(forkDir, VCS.GIT);\n            var forkGitConfig = forkDir.resolve(\".git\").resolve(\"config\");\n            Files.write(forkGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toFork = new TestHostedRepository(host, \"test-mirror-fork\", forkLocalRepo);\n\n            var now = ZonedDateTime.now();\n            var fromFileA = fromDir.resolve(\"a.txt\");\n            Files.writeString(fromFileA, \"Hello A\\n\");\n            fromLocalRepo.add(fromFileA);\n            var fromHashA = fromLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(fromHashA, fromCommits.get(0).hash());\n\n            var toFileA = toDir.resolve(\"a.txt\");\n            Files.writeString(toFileA, \"Hello A\\n\");\n            toLocalRepo.add(toFileA);\n            var toHashA = toLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(toHashA, toCommits.get(0).hash());\n            assertEquals(fromHashA, toHashA);\n\n            var fromFileB = fromDir.resolve(\"b.txt\");\n            Files.writeString(fromFileB, \"Hello B1\\n\");\n            fromLocalRepo.add(fromFileB);\n            var fromHashB = fromLocalRepo.commit(\"Adding b1.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var toFileB = toDir.resolve(\"b.txt\");\n            Files.writeString(toFileB, \"Hello B2\\n\");\n            toLocalRepo.add(toFileB);\n            var toHashB = toLocalRepo.commit(\"Adding b2.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var storage = temp.path().resolve(\"storage\");\n            var master = new Branch(\"master\");\n            var specs = List.of(new MergeBot.Spec(fromHostedRepo, master, master));\n            var bot = new MergeBot(storage, toHostedRepo, toFork, specs);\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(2, toCommits.size());\n            var toHashes = toCommits.stream().map(Commit::hash).collect(Collectors.toSet());\n            assertTrue(toHashes.contains(toHashA));\n            assertTrue(toHashes.contains(toHashB));\n\n            var pullRequests = toHostedRepo.openPullRequests();\n            assertEquals(1, pullRequests.size());\n            var pr = pullRequests.get(0);\n            assertEquals(\"Merge test:master\", pr.title());\n\n            var fromDir2 = temp.path().resolve(\"from2.git\");\n            var fromLocalRepo2 = TestableRepository.init(fromDir2, VCS.GIT);\n            var fromHostedRepo2 = new TestHostedRepository(host, \"test-2\", fromLocalRepo2);\n\n            var host2 = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n            var toDir2 = temp.path().resolve(\"to2.git\");\n            var toLocalRepo2 = TestableRepository.init(toDir2, VCS.GIT);\n            var toGitConfig2 = toDir2.resolve(\".git\").resolve(\"config\");\n            Files.write(toGitConfig2, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo2 = new TestHostedRepository(host2, \"test-mirror-2\", toLocalRepo2);\n\n            var forkDir2 = temp.path().resolve(\"fork2.git\");\n            var forkLocalRepo2 = TestableRepository.init(forkDir2, VCS.GIT);\n            var forkGitConfig2 = forkDir2.resolve(\".git\").resolve(\"config\");\n            Files.write(forkGitConfig2, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toFork2 = new TestHostedRepository(host2, \"test-mirror-fork-2\", forkLocalRepo2);\n\n            var now2 = ZonedDateTime.now();\n            var fromFileA2 = fromDir2.resolve(\"a2.txt\");\n            Files.writeString(fromFileA2, \"Hello A2\\n\");\n            fromLocalRepo2.add(fromFileA2);\n            var fromHashA2 = fromLocalRepo2.commit(\"Adding a2.txt\", \"duke\", \"duke@openjdk.org\", now2);\n\n            var toFileA2 = toDir2.resolve(\"a2.txt\");\n            Files.writeString(toFileA2, \"Hello A2\\n\");\n            toLocalRepo2.add(toFileA2);\n            var toHashA2 = toLocalRepo2.commit(\"Adding a2.txt\", \"duke\", \"duke@openjdk.org\", now2);\n            var toCommits2 = toLocalRepo2.commits().asList();\n            assertEquals(1, toCommits2.size());\n            assertEquals(toHashA2, toCommits2.get(0).hash());\n            assertEquals(fromHashA2, toHashA2);\n\n            var fromFileB2 = fromDir2.resolve(\"b2.txt\");\n            Files.writeString(fromFileB2, \"Hello B2\\n\");\n            fromLocalRepo2.add(fromFileB2);\n            var fromHashB2 = fromLocalRepo2.commit(\"Adding b2.txt\", \"duke\", \"duke@openjdk.org\");\n            var fromCommits2 = fromLocalRepo2.commits().asList();\n            assertEquals(2, fromCommits2.size());\n            assertEquals(fromHashB2, fromCommits2.get(0).hash());\n            assertEquals(fromHashA2, fromCommits2.get(1).hash());\n\n            var storage2 = temp.path().resolve(\"storage-2\");\n            var master2 = new Branch(\"master\");\n            var specs2 = List.of(new MergeBot.Spec(fromHostedRepo2, master2, master2, null, \"master\", List.of(), List.of(toHostedRepo)));\n            var bot2 = new MergeBot(storage2, toHostedRepo2, toFork2, specs2);\n            TestBotRunner.runPeriodicItems(bot2);\n\n            var toCommitsAfterMerge2 = toLocalRepo2.commits().asList();\n            assertEquals(1, toCommitsAfterMerge2.size());\n            assertEquals(toHashA2, toCommitsAfterMerge2.get(0).hash());\n            assertEquals(toHashA2, toLocalRepo2.resolve(\"master\").get());\n\n            pr.setState(Issue.State.CLOSED);\n            TestBotRunner.runPeriodicItems(bot2);\n            toCommitsAfterMerge2 = toLocalRepo2.commits().asList();\n            assertEquals(2, toCommitsAfterMerge2.size());\n            assertEquals(fromHashB2, toCommitsAfterMerge2.get(0).hash());\n            assertEquals(toHashA2, toCommitsAfterMerge2.get(1).hash());\n            assertEquals(fromHashB2, toLocalRepo2.resolve(\"master\").get());\n        }\n    }\n\n    @Test\n    void failingMergeShouldResultInOnlyOnePR(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var toGitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(toGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var forkDir = temp.path().resolve(\"fork.git\");\n            var forkLocalRepo = TestableRepository.init(forkDir, VCS.GIT);\n            var forkGitConfig = forkDir.resolve(\".git\").resolve(\"config\");\n            Files.write(forkGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toFork = new TestHostedRepository(host, \"test-mirror-fork\", forkLocalRepo);\n\n            var now = ZonedDateTime.now();\n            var fromFileA = fromDir.resolve(\"a.txt\");\n            Files.writeString(fromFileA, \"Hello A\\n\");\n            fromLocalRepo.add(fromFileA);\n            var fromHashA = fromLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(fromHashA, fromCommits.get(0).hash());\n\n            var toFileA = toDir.resolve(\"a.txt\");\n            Files.writeString(toFileA, \"Hello A\\n\");\n            toLocalRepo.add(toFileA);\n            var toHashA = toLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(toHashA, toCommits.get(0).hash());\n            assertEquals(fromHashA, toHashA);\n\n            var fromFileB = fromDir.resolve(\"b.txt\");\n            Files.writeString(fromFileB, \"Hello B1\\n\");\n            fromLocalRepo.add(fromFileB);\n            var fromHashB = fromLocalRepo.commit(\"Adding b1.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var toFileB = toDir.resolve(\"b.txt\");\n            Files.writeString(toFileB, \"Hello B2\\n\");\n            toLocalRepo.add(toFileB);\n            var toHashB = toLocalRepo.commit(\"Adding b2.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var storage = temp.path().resolve(\"storage\");\n            var master = new Branch(\"master\");\n            var specs = List.of(new MergeBot.Spec(fromHostedRepo, master, master));\n            var bot = new MergeBot(storage, toHostedRepo, toFork, specs);\n            TestBotRunner.runPeriodicItems(bot);\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(2, toCommits.size());\n            var toHashes = toCommits.stream().map(Commit::hash).collect(Collectors.toSet());\n            assertTrue(toHashes.contains(toHashA));\n            assertTrue(toHashes.contains(toHashB));\n\n            var pullRequests = toHostedRepo.openPullRequests();\n            assertEquals(1, pullRequests.size());\n            var pr = pullRequests.get(0);\n            assertEquals(\"Merge test:master\", pr.title());\n        }\n    }\n\n    final static class TestClock implements Clock {\n        ZonedDateTime now;\n\n        TestClock() {\n            this(null);\n        }\n\n        TestClock(ZonedDateTime now) {\n            this.now = now;\n        }\n\n        @Override\n        public ZonedDateTime now() {\n            return now;\n        }\n    }\n\n    @Test\n    void testMergeHourly(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var toGitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(toGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var forkDir = temp.path().resolve(\"fork.git\");\n            var forkLocalRepo = TestableRepository.init(forkDir, VCS.GIT);\n            var forkGitConfig = forkDir.resolve(\".git\").resolve(\"config\");\n            Files.write(forkGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toFork = new TestHostedRepository(host, \"test-mirror-fork\", forkLocalRepo);\n\n            var now = ZonedDateTime.now();\n            var fromFileA = fromDir.resolve(\"a.txt\");\n            Files.writeString(fromFileA, \"Hello A\\n\");\n            fromLocalRepo.add(fromFileA);\n            var fromHashA = fromLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(fromHashA, fromCommits.get(0).hash());\n\n            var toFileA = toDir.resolve(\"a.txt\");\n            Files.writeString(toFileA, \"Hello A\\n\");\n            toLocalRepo.add(toFileA);\n            var toHashA = toLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(toHashA, toCommits.get(0).hash());\n            assertEquals(fromHashA, toHashA);\n\n            var fromFileB = fromDir.resolve(\"b.txt\");\n            Files.writeString(fromFileB, \"Hello B\\n\");\n            fromLocalRepo.add(fromFileB);\n            var fromHashB = fromLocalRepo.commit(\"Adding b.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var toFileC = toDir.resolve(\"c.txt\");\n            Files.writeString(toFileC, \"Hello C\\n\");\n            toLocalRepo.add(toFileC);\n            var toHashC = toLocalRepo.commit(\"Adding c.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var storage = temp.path().resolve(\"storage\");\n            var master = new Branch(\"master\");\n\n            // Merge only at most once during the first minute every hour\n            var freq = MergeBot.Spec.Frequency.hourly(1);\n            var specs = List.of(new MergeBot.Spec(fromHostedRepo, master, master, freq));\n\n            var clock = new TestClock(ZonedDateTime.of(2020, 1, 23, 15, 0, 0, 0, ZoneId.of(\"GMT+1\")));\n            var bot = new MergeBot(storage, toHostedRepo, toFork, specs, clock);\n\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Ensure nothing has been merged\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(2, toCommits.size());\n            assertEquals(toHashC, toCommits.get(0).hash());\n            assertEquals(toHashA, toCommits.get(1).hash());\n\n            // Set the clock to the first minute of the hour\n            clock.now = ZonedDateTime.of(2020, 1, 23, 15, 1, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Should have merged\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n            var hashes = toCommits.stream().map(Commit::hash).collect(Collectors.toSet());\n            assertTrue(hashes.contains(toHashA));\n            assertTrue(hashes.contains(fromHashB));\n            assertTrue(hashes.contains(toHashC));\n\n            var known = Set.of(toHashA, fromHashB, toHashC);\n            var merge = toCommits.stream().filter(c -> !known.contains(c.hash())).findAny().get();\n            assertTrue(merge.isMerge());\n            assertEquals(List.of(\"Automatic merge of test:master into master\"), merge.message());\n            assertEquals(\"duke\", merge.author().name());\n            assertEquals(\"duke@openjdk.org\", merge.author().email());\n\n            assertEquals(0, toHostedRepo.openPullRequests().size());\n\n            var fromFileD = fromDir.resolve(\"d.txt\");\n            Files.writeString(fromFileD, \"Hello D\\n\");\n            fromLocalRepo.add(fromFileD);\n            var fromHashD = fromLocalRepo.commit(\"Adding d.txt\", \"duke\", \"duke@openjdk.org\");\n\n            // Since the time hasn't changed it should not merge again\n            TestBotRunner.runPeriodicItems(bot);\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n\n            // Move the minutes forward, the bot should not merge\n            clock.now = ZonedDateTime.of(2020, 1, 23, 15, 45, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n\n            // Move the clock forward one hour, the bot should merge\n            clock.now = ZonedDateTime.of(2020, 1, 23, 16, 1, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(6, toCommits.size());\n        }\n    }\n\n    @Test\n    void testMergeDaily(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var toGitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(toGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test\", toLocalRepo);\n\n            var forkDir = temp.path().resolve(\"fork.git\");\n            var forkLocalRepo = TestableRepository.init(forkDir, VCS.GIT);\n            var forkGitConfig = forkDir.resolve(\".git\").resolve(\"config\");\n            Files.write(forkGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toFork = new TestHostedRepository(host, \"test-mirror-fork\", forkLocalRepo);\n\n            var now = ZonedDateTime.now();\n            var fromFileA = fromDir.resolve(\"a.txt\");\n            Files.writeString(fromFileA, \"Hello A\\n\");\n            fromLocalRepo.add(fromFileA);\n            var fromHashA = fromLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(fromHashA, fromCommits.get(0).hash());\n\n            var toFileA = toDir.resolve(\"a.txt\");\n            Files.writeString(toFileA, \"Hello A\\n\");\n            toLocalRepo.add(toFileA);\n            var toHashA = toLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(toHashA, toCommits.get(0).hash());\n            assertEquals(fromHashA, toHashA);\n\n            var fromFileB = fromDir.resolve(\"b.txt\");\n            Files.writeString(fromFileB, \"Hello B\\n\");\n            fromLocalRepo.add(fromFileB);\n            var fromHashB = fromLocalRepo.commit(\"Adding b.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var toFileC = toDir.resolve(\"c.txt\");\n            Files.writeString(toFileC, \"Hello C\\n\");\n            toLocalRepo.add(toFileC);\n            var toHashC = toLocalRepo.commit(\"Adding c.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var storage = temp.path().resolve(\"storage\");\n            var master = new Branch(\"master\");\n\n            // Merge only at most once during the third hour every day\n            var freq = MergeBot.Spec.Frequency.daily(3);\n            var specs = List.of(new MergeBot.Spec(fromHostedRepo, master, master, freq));\n\n            var clock = new TestClock(ZonedDateTime.of(2020, 1, 23, 2, 45, 0, 0, ZoneId.of(\"GMT+1\")));\n            var bot = new MergeBot(storage, toHostedRepo, toFork, specs, clock);\n\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Ensure nothing has been merged\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(2, toCommits.size());\n            assertEquals(toHashC, toCommits.get(0).hash());\n            assertEquals(toHashA, toCommits.get(1).hash());\n\n            // Set the clock to the third hour of the day (minutes should not matter)\n            clock.now = ZonedDateTime.of(2020, 1, 23, 3, 37, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Should have merged\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n            var hashes = toCommits.stream().map(Commit::hash).collect(Collectors.toSet());\n            assertTrue(hashes.contains(toHashA));\n            assertTrue(hashes.contains(fromHashB));\n            assertTrue(hashes.contains(toHashC));\n\n            var known = Set.of(toHashA, fromHashB, toHashC);\n            var merge = toCommits.stream().filter(c -> !known.contains(c.hash())).findAny().get();\n            assertTrue(merge.isMerge());\n            assertEquals(List.of(\"Automatic merge of master into master\"), merge.message());\n            assertEquals(\"duke\", merge.author().name());\n            assertEquals(\"duke@openjdk.org\", merge.author().email());\n\n            assertEquals(0, toHostedRepo.openPullRequests().size());\n\n            var fromFileD = fromDir.resolve(\"d.txt\");\n            Files.writeString(fromFileD, \"Hello D\\n\");\n            fromLocalRepo.add(fromFileD);\n            var fromHashD = fromLocalRepo.commit(\"Adding d.txt\", \"duke\", \"duke@openjdk.org\");\n\n            // Since the time hasn't changed it should not merge\n            TestBotRunner.runPeriodicItems(bot);\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n\n            // Move the minutes forward, the bot should not merge\n            clock.now = ZonedDateTime.of(2020, 1, 23, 3, 45, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n\n            // Move the hours forward, the bot should not merge\n            clock.now = ZonedDateTime.of(2020, 1, 23, 17, 45, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n\n            // Move the clock forward one day, the bot should merge\n            clock.now = ZonedDateTime.of(2020, 1, 24, 3, 55, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(6, toCommits.size());\n        }\n    }\n\n    @Test\n    void testMergeWeekly(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var toGitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(toGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var forkDir = temp.path().resolve(\"fork.git\");\n            var forkLocalRepo = TestableRepository.init(forkDir, VCS.GIT);\n            var forkGitConfig = forkDir.resolve(\".git\").resolve(\"config\");\n            Files.write(forkGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toFork = new TestHostedRepository(host, \"test-mirror-fork\", forkLocalRepo);\n\n            var now = ZonedDateTime.now();\n            var fromFileA = fromDir.resolve(\"a.txt\");\n            Files.writeString(fromFileA, \"Hello A\\n\");\n            fromLocalRepo.add(fromFileA);\n            var fromHashA = fromLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(fromHashA, fromCommits.get(0).hash());\n\n            var toFileA = toDir.resolve(\"a.txt\");\n            Files.writeString(toFileA, \"Hello A\\n\");\n            toLocalRepo.add(toFileA);\n            var toHashA = toLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(toHashA, toCommits.get(0).hash());\n            assertEquals(fromHashA, toHashA);\n\n            var fromFileB = fromDir.resolve(\"b.txt\");\n            Files.writeString(fromFileB, \"Hello B\\n\");\n            fromLocalRepo.add(fromFileB);\n            var fromHashB = fromLocalRepo.commit(\"Adding b.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var toFileC = toDir.resolve(\"c.txt\");\n            Files.writeString(toFileC, \"Hello C\\n\");\n            toLocalRepo.add(toFileC);\n            var toHashC = toLocalRepo.commit(\"Adding c.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var storage = temp.path().resolve(\"storage\");\n            var master = new Branch(\"master\");\n\n            // Merge only at most once per week on Friday's at 12:00\n            var freq = MergeBot.Spec.Frequency.weekly(DayOfWeek.FRIDAY, 12);\n            var specs = List.of(new MergeBot.Spec(fromHostedRepo, master, master, freq));\n\n            var clock = new TestClock(ZonedDateTime.of(2020, 1, 24, 11, 45, 0, 0, ZoneId.of(\"GMT+1\")));\n            var bot = new MergeBot(storage, toHostedRepo, toFork, specs, clock);\n\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Ensure nothing has been merged\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(2, toCommits.size());\n            assertEquals(toHashC, toCommits.get(0).hash());\n            assertEquals(toHashA, toCommits.get(1).hash());\n\n            // Set the clock to the 12th hour of the day (minutes should not matter)\n            clock.now = ZonedDateTime.of(2020, 1, 24, 12, 37, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Should have merged\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n            var hashes = toCommits.stream().map(Commit::hash).collect(Collectors.toSet());\n            assertTrue(hashes.contains(toHashA));\n            assertTrue(hashes.contains(fromHashB));\n            assertTrue(hashes.contains(toHashC));\n\n            var known = Set.of(toHashA, fromHashB, toHashC);\n            var merge = toCommits.stream().filter(c -> !known.contains(c.hash())).findAny().get();\n            assertTrue(merge.isMerge());\n            assertEquals(List.of(\"Automatic merge of test:master into master\"), merge.message());\n            assertEquals(\"duke\", merge.author().name());\n            assertEquals(\"duke@openjdk.org\", merge.author().email());\n\n            assertEquals(0, toHostedRepo.openPullRequests().size());\n\n            var fromFileD = fromDir.resolve(\"d.txt\");\n            Files.writeString(fromFileD, \"Hello D\\n\");\n            fromLocalRepo.add(fromFileD);\n            var fromHashD = fromLocalRepo.commit(\"Adding d.txt\", \"duke\", \"duke@openjdk.org\");\n\n            // Since the time hasn't changed it should not merge\n            TestBotRunner.runPeriodicItems(bot);\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n\n            // Move the hours forward, the bot should not merge\n            clock.now = ZonedDateTime.of(2020, 1, 24, 13, 45, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n\n            // Move the days forward, the bot should not merge\n            clock.now = ZonedDateTime.of(2020, 1, 25, 13, 45, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n\n            // Move the clock forward one week, the bot should merge\n            clock.now = ZonedDateTime.of(2020, 1, 31, 12, 29, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(6, toCommits.size());\n        }\n    }\n\n    @Test\n    void testMergeMonthly(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var toGitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(toGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test\", toLocalRepo);\n\n            var forkDir = temp.path().resolve(\"fork.git\");\n            var forkLocalRepo = TestableRepository.init(forkDir, VCS.GIT);\n            var forkGitConfig = forkDir.resolve(\".git\").resolve(\"config\");\n            Files.write(forkGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toFork = new TestHostedRepository(host, \"test-mirror-fork\", forkLocalRepo);\n\n            var now = ZonedDateTime.now();\n            var fromFileA = fromDir.resolve(\"a.txt\");\n            Files.writeString(fromFileA, \"Hello A\\n\");\n            fromLocalRepo.add(fromFileA);\n            var fromHashA = fromLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(fromHashA, fromCommits.get(0).hash());\n\n            var toFileA = toDir.resolve(\"a.txt\");\n            Files.writeString(toFileA, \"Hello A\\n\");\n            toLocalRepo.add(toFileA);\n            var toHashA = toLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(toHashA, toCommits.get(0).hash());\n            assertEquals(fromHashA, toHashA);\n\n            var fromFileB = fromDir.resolve(\"b.txt\");\n            Files.writeString(fromFileB, \"Hello B\\n\");\n            fromLocalRepo.add(fromFileB);\n            var fromHashB = fromLocalRepo.commit(\"Adding b.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var toFileC = toDir.resolve(\"c.txt\");\n            Files.writeString(toFileC, \"Hello C\\n\");\n            toLocalRepo.add(toFileC);\n            var toHashC = toLocalRepo.commit(\"Adding c.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var storage = temp.path().resolve(\"storage\");\n            var master = new Branch(\"master\");\n\n            // Merge only at most once per month on the 17th day at at 11:00\n            var freq = MergeBot.Spec.Frequency.monthly(17, 11);\n            var specs = List.of(new MergeBot.Spec(fromHostedRepo, master, master, freq));\n\n            var clock = new TestClock(ZonedDateTime.of(2020, 1, 16, 11, 0, 0, 0, ZoneId.of(\"GMT+1\")));\n            var bot = new MergeBot(storage, toHostedRepo, toFork, specs, clock);\n\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Ensure nothing has been merged\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(2, toCommits.size());\n            assertEquals(toHashC, toCommits.get(0).hash());\n            assertEquals(toHashA, toCommits.get(1).hash());\n\n            // Set the clock to the 17th day and at hour 11 (minutes should not matter)\n            clock.now = ZonedDateTime.of(2020, 1, 17, 11, 37, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Should have merged\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n            var hashes = toCommits.stream().map(Commit::hash).collect(Collectors.toSet());\n            assertTrue(hashes.contains(toHashA));\n            assertTrue(hashes.contains(fromHashB));\n            assertTrue(hashes.contains(toHashC));\n\n            var known = Set.of(toHashA, fromHashB, toHashC);\n            var merge = toCommits.stream().filter(c -> !known.contains(c.hash())).findAny().get();\n            assertTrue(merge.isMerge());\n            assertEquals(List.of(\"Automatic merge of master into master\"), merge.message());\n            assertEquals(\"duke\", merge.author().name());\n            assertEquals(\"duke@openjdk.org\", merge.author().email());\n\n            assertEquals(0, toHostedRepo.openPullRequests().size());\n\n            var fromFileD = fromDir.resolve(\"d.txt\");\n            Files.writeString(fromFileD, \"Hello D\\n\");\n            fromLocalRepo.add(fromFileD);\n            var fromHashD = fromLocalRepo.commit(\"Adding d.txt\", \"duke\", \"duke@openjdk.org\");\n\n            // Since the time hasn't changed it should not merge\n            TestBotRunner.runPeriodicItems(bot);\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n\n            // Move the hours forward, the bot should not merge\n            clock.now = ZonedDateTime.of(2020, 1, 17, 12, 45, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n\n            // Move the days forward, the bot should not merge\n            clock.now = ZonedDateTime.of(2020, 1, 18, 11, 0, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n\n            // Move the clock forward one month, the bot should merge\n            clock.now = ZonedDateTime.of(2020, 2, 17, 11, 55, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(6, toCommits.size());\n        }\n    }\n\n    @Test\n    void testMergeYearly(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var toGitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(toGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test\", toLocalRepo);\n\n            var forkDir = temp.path().resolve(\"fork.git\");\n            var forkLocalRepo = TestableRepository.init(forkDir, VCS.GIT);\n            var forkGitConfig = forkDir.resolve(\".git\").resolve(\"config\");\n            Files.write(forkGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toFork = new TestHostedRepository(host, \"test-mirror-fork\", forkLocalRepo);\n\n            var now = ZonedDateTime.now();\n            var fromFileA = fromDir.resolve(\"a.txt\");\n            Files.writeString(fromFileA, \"Hello A\\n\");\n            fromLocalRepo.add(fromFileA);\n            var fromHashA = fromLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(fromHashA, fromCommits.get(0).hash());\n\n            var toFileA = toDir.resolve(\"a.txt\");\n            Files.writeString(toFileA, \"Hello A\\n\");\n            toLocalRepo.add(toFileA);\n            var toHashA = toLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(toHashA, toCommits.get(0).hash());\n            assertEquals(fromHashA, toHashA);\n\n            var fromFileB = fromDir.resolve(\"b.txt\");\n            Files.writeString(fromFileB, \"Hello B\\n\");\n            fromLocalRepo.add(fromFileB);\n            var fromHashB = fromLocalRepo.commit(\"Adding b.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var toFileC = toDir.resolve(\"c.txt\");\n            Files.writeString(toFileC, \"Hello C\\n\");\n            toLocalRepo.add(toFileC);\n            var toHashC = toLocalRepo.commit(\"Adding c.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var storage = temp.path().resolve(\"storage\");\n            var master = new Branch(\"master\");\n\n            // Merge only at most once per year on the 29th day of May at at 07:00\n            var freq = MergeBot.Spec.Frequency.yearly(Month.MAY, 29, 07);\n            var specs = List.of(new MergeBot.Spec(fromHostedRepo, master, master, freq));\n\n            var clock = new TestClock(ZonedDateTime.of(2020, 5, 27, 11, 0, 0, 0, ZoneId.of(\"GMT+1\")));\n            var bot = new MergeBot(storage, toHostedRepo, toFork, specs, clock);\n\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Ensure nothing has been merged\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(2, toCommits.size());\n            assertEquals(toHashC, toCommits.get(0).hash());\n            assertEquals(toHashA, toCommits.get(1).hash());\n\n            // Set the clock to the 29th of May and at hour 11 (minutes should not matter)\n            clock.now = ZonedDateTime.of(2020, 5, 29, 7, 37, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Should have merged\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n            var hashes = toCommits.stream().map(Commit::hash).collect(Collectors.toSet());\n            assertTrue(hashes.contains(toHashA));\n            assertTrue(hashes.contains(fromHashB));\n            assertTrue(hashes.contains(toHashC));\n\n            var known = Set.of(toHashA, fromHashB, toHashC);\n            var merge = toCommits.stream().filter(c -> !known.contains(c.hash())).findAny().get();\n            assertTrue(merge.isMerge());\n            assertEquals(List.of(\"Automatic merge of master into master\"), merge.message());\n            assertEquals(\"duke\", merge.author().name());\n            assertEquals(\"duke@openjdk.org\", merge.author().email());\n\n            assertEquals(0, toHostedRepo.openPullRequests().size());\n\n            var fromFileD = fromDir.resolve(\"d.txt\");\n            Files.writeString(fromFileD, \"Hello D\\n\");\n            fromLocalRepo.add(fromFileD);\n            var fromHashD = fromLocalRepo.commit(\"Adding d.txt\", \"duke\", \"duke@openjdk.org\");\n\n            // Since the time hasn't changed it should not merge again\n            TestBotRunner.runPeriodicItems(bot);\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n\n            // Move the hours forward, the bot should not merge\n            clock.now = ZonedDateTime.of(2020, 5, 29, 8, 45, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n\n            // Move the days forward, the bot should not merge\n            clock.now = ZonedDateTime.of(2020, 5, 30, 11, 0, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n\n            // Move the months forward, the bot should not merge\n            clock.now = ZonedDateTime.of(2020, 7, 29, 7, 0, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(4, toCommits.size());\n\n            // Move the clock forward one year, the bot should merge\n            clock.now = ZonedDateTime.of(2021, 5, 29, 7, 55, 0, 0, ZoneId.of(\"GMT+1\"));\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(6, toCommits.size());\n        }\n    }\n\n    @Test\n    void mergeAfterDivergedStorage(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var toGitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(toGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                    StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var forkDir = temp.path().resolve(\"fork.git\");\n            var forkLocalRepo = TestableRepository.init(forkDir, VCS.GIT);\n            var forkGitConfig = forkDir.resolve(\".git\").resolve(\"config\");\n            Files.write(forkGitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                    StandardOpenOption.APPEND);\n            var toFork = new TestHostedRepository(host, \"test-mirror-fork\", forkLocalRepo);\n\n            var now = ZonedDateTime.now();\n            var fromFileA = fromDir.resolve(\"a.txt\");\n            Files.writeString(fromFileA, \"Hello A\\n\");\n            fromLocalRepo.add(fromFileA);\n            var fromHashA = fromLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(fromHashA, fromCommits.get(0).hash());\n\n            var toFileA = toDir.resolve(\"a.txt\");\n            Files.writeString(toFileA, \"Hello A\\n\");\n            toLocalRepo.add(toFileA);\n            var toHashA = toLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(toHashA, toCommits.get(0).hash());\n            assertEquals(fromHashA, toHashA);\n\n            var storage = temp.path().resolve(\"storage\");\n            var master = new Branch(\"master\");\n            var specs = List.of(new MergeBot.Spec(fromHostedRepo, master, master));\n            var bot = new MergeBot(storage, toHostedRepo, toFork, specs);\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Add something new to the source\n            var fromFileB = fromDir.resolve(\"b.txt\");\n            Files.writeString(fromFileB, \"Hello B\\n\");\n            fromLocalRepo.add(fromFileB);\n            var fromHashB = fromLocalRepo.commit(\"Adding a.txt\", \"duke\", \"duke@openjdk.org\", now);\n            fromLocalRepo.push(fromHashB, fromHostedRepo.authenticatedUrl(), \"master\");\n\n            // Diverge the target with something non-conflicting\n            var toFileC = toDir.resolve(\"c.txt\");\n            Files.writeString(toFileC, \"Hello C\\n\");\n            toLocalRepo.add(toFileC);\n            var toHashC = toLocalRepo.commit(\"Adding c.txt\", \"duke\", \"duke@openjdk.org\");\n            toLocalRepo.push(toHashC, toHostedRepo.authenticatedUrl(), \"master\");\n\n            // But push something out of place to the local storage as well\n            var sanitizedForkUrl = URLEncoder.encode(toFork.webUrl().toString(), StandardCharsets.UTF_8);\n            var storageRepo = TestableRepository.init(storage.resolve(sanitizedForkUrl), VCS.GIT);\n            var divergedForkFile = storageRepo.root().resolve(\"d.txt\");\n            Files.writeString(divergedForkFile, \"Hello D\\n\");\n            storageRepo.add(divergedForkFile);\n            var divergedForkHash = storageRepo.commit(\"Adding d.txt\", \"duke\", \"duke@openjdk.org\");\n\n            // This will need manual intervention\n            assertThrows(RuntimeException.class, () -> TestBotRunner.runPeriodicItems(bot));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/mirror/build.gradle",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.mirror'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.bots.mirror' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':ci')\n    implementation project(':host')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':bot')\n    implementation project(':census')\n    implementation project(':json')\n    implementation project(':vcs')\n    implementation project(':metrics')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "bots/mirror/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.bots.mirror {\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.vcs;\n    requires java.logging;\n\n    provides org.openjdk.skara.bot.BotFactory with org.openjdk.skara.bots.mirror.MirrorBotFactory;\n}\n"
  },
  {
    "path": "bots/mirror/src/main/java/org/openjdk/skara/bots/mirror/MirrorBot.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mirror;\n\nimport java.util.regex.Pattern;\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.net.URLEncoder;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\n/**\n * The MirrorBot mirrors one HostedRepository to another. It can be configured\n * to only mirror a specific set of branches, or everything (which also\n * includes tags). When only mirroring a set of branches, the includeTags\n * setting can be used to also include tags.\n */\nclass MirrorBot implements Bot, WorkItem {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n    private final Path storage;\n    private final HostedRepository from;\n    private final HostedRepository to;\n    private final List<Pattern> branchPatterns;\n    private final boolean includeTags;\n    private final boolean onlyTags;\n    private final List<String> refspecs;\n\n    MirrorBot(Path storage, HostedRepository from, HostedRepository to) {\n        this(storage, from, to, List.of(), true, false, List.of());\n    }\n\n    MirrorBot(Path storage, HostedRepository from, HostedRepository to, List<Pattern> branchPatterns,\n              boolean includeTags, boolean onlyTags, List<String> refspecs) {\n        this.storage = storage;\n        this.from = from;\n        this.to = to;\n        this.branchPatterns = branchPatterns;\n        this.includeTags = includeTags;\n        this.onlyTags = onlyTags;\n        this.refspecs = refspecs;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof MirrorBot otherBot)) {\n            return true;\n        }\n        return !to.name().equals(otherBot.to.name());\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        try {\n            var sanitizedUrl =\n                URLEncoder.encode(to.webUrl().toString(), StandardCharsets.UTF_8);\n            var dir = storage.resolve(sanitizedUrl);\n            Repository repo = null;\n\n\n            if (!Files.exists(dir)) {\n                log.info(\"Cloning \" + from.name());\n                Files.createDirectories(dir);\n                repo = Repository.mirror(from.authenticatedUrl(), dir);\n            } else {\n                log.info(\"Found existing scratch directory for \" + to.name());\n                repo = Repository.get(dir).orElseGet(() -> {\n                    log.info(\"The existing scratch directory is not a valid repository. Recloning \" + from.name());\n                    try {\n                        try (var paths = Files.walk(dir)) {\n                            paths.map(Path::toFile)\n                                 .sorted(Comparator.reverseOrder())\n                                 .forEach(File::delete);\n                        }\n                        return Repository.mirror(from.authenticatedUrl(), dir);\n                    } catch (IOException io) {\n                        throw new RuntimeException(io);\n                    }\n                });\n            }\n\n            log.info(\"Pulling \" + from.name());\n            repo.fetchAll(from.authenticatedUrl(), includeTags || onlyTags || !refspecs.isEmpty());\n            if (onlyTags) {\n                log.info(\"Pushing tags to \" + to.name());\n                repo.pushTags(to.authenticatedUrl(), true);\n            } else if (branchPatterns.isEmpty() && includeTags) {\n                log.info(\"Pushing tags and branches to \" + to.name());\n                repo.pushAll(to.authenticatedUrl(), true);\n            } else if (!branchPatterns.isEmpty()) {\n                for (var branch : repo.branches()) {\n                    if (branchPatterns.stream().anyMatch(p -> p.matcher(branch.name()).matches())) {\n                        var hash = repo.resolve(branch);\n                        if (hash.isPresent()) {\n                            log.info(\"Pushing branch \" + branch.name() + \" to \" + to.name() + \" \" +\n                                     (includeTags ? \"including\" : \"excluding\") + \" tags\");\n                            repo.push(hash.get(), to.authenticatedUrl(), branch.name(), true, includeTags);\n                        } else {\n                            log.severe(\"Branch \" + branch + \" not found in repo \" + repo);\n                        }\n                    }\n                }\n            } else if (!refspecs.isEmpty()) {\n                for (var refspec : refspecs) {\n                    log.info(\"Pushing using refspec \" + refspec + \" to \" + to.name());\n                    repo.push(refspec, to.authenticatedUrl());\n                }\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        return List.of();\n    }\n\n    @Override\n    public String toString() {\n        var name = \"MirrorBot@\" + from.name() + \"->\" + to.name();\n        if (!refspecs.isEmpty()) {\n            name += \" (\" + String.join(\",\", refspecs) + \")\";\n        } else {\n            if (branchPatterns.isEmpty()) {\n                if (onlyTags) {\n                    name += \" ()\";\n                } else {\n                    name += \" (*)\";\n                }\n            } else {\n                var branchPatterns = this.branchPatterns.stream().map(Pattern::toString).collect(Collectors.toList());\n                name += \" (\" + String.join(\",\", branchPatterns) + \")\";\n            }\n            if (onlyTags) {\n                name += \" [tags only]\";\n            } else if (includeTags) {\n                name += \" [tags included]\";\n            } else {\n                name += \" [tags excluded]\";\n            }\n        }\n        return name;\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        return List.of(this);\n    }\n\n    @Override\n    public String workItemName() {\n        return botName();\n    }\n\n    @Override\n    public String botName() {\n        return name();\n    }\n\n    @Override\n    public String name() {\n        return MirrorBotFactory.NAME;\n    }\n\n    public List<Pattern> getBranchPatterns() {\n        return branchPatterns;\n    }\n\n    public boolean isIncludeTags() {\n        return includeTags;\n    }\n\n    public boolean isOnlyTags() {\n        return onlyTags;\n    }\n\n    public List<String> getRefspecs() {\n        return refspecs;\n    }\n}\n"
  },
  {
    "path": "bots/mirror/src/main/java/org/openjdk/skara/bots/mirror/MirrorBotFactory.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mirror;\n\nimport java.util.regex.Pattern;\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.json.JSONValue;\nimport org.openjdk.skara.vcs.Branch;\n\nimport java.io.*;\nimport java.nio.file.Files;\nimport java.util.*;\nimport java.util.stream.Collectors;\nimport java.util.logging.Logger;\n\npublic class MirrorBotFactory implements BotFactory {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n\n    static final String NAME = \"mirror\";\n    @Override\n    public String name() {\n        return NAME;\n    }\n\n    @Override\n    public List<Bot> create(BotConfiguration configuration) {\n        var storage = configuration.storageFolder();\n        try {\n            Files.createDirectories(storage);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        var specific = configuration.specific();\n\n        var bots = new ArrayList<Bot>();\n        for (var repo : specific.get(\"repositories\").asArray()) {\n            var fromName = repo.get(\"from\").asString();\n            var fromRepo = configuration.repository(fromName);\n\n            var toName = repo.get(\"to\").asString();\n            var toRepo = configuration.repository(toName);\n\n            List<String> refspecs;\n            if (repo.contains(\"refspecs\")) {\n                var refspecsElement = repo.get(\"refspecs\");\n                if (refspecsElement.isArray()) {\n                    refspecs = refspecsElement.asArray().stream()\n                            .map(JSONValue::asString)\n                            .toList();\n                } else {\n                    refspecs = List.of(refspecsElement.asString());\n                }\n            } else {\n                refspecs = List.of();\n            }\n\n            List<Pattern> branchPatterns;\n            if (repo.contains(\"branches\")) {\n                if (!refspecs.isEmpty()) {\n                    throw new IllegalStateException(\"Cannot combine refspecs and branches\");\n                }\n                // Accept both an array of regex patterns as well as a single comma separated\n                // string for backwards compatibility\n                var branchesElement = repo.get(\"branches\");\n                if (branchesElement.isArray()) {\n                    branchPatterns = branchesElement.asArray().stream()\n                            .map(JSONValue::asString)\n                            .map(Pattern::compile)\n                            .toList();\n                } else {\n                    branchPatterns = Arrays.stream(repo.get(\"branches\").asString().split(\",\"))\n                            .map(Pattern::compile)\n                            .toList();\n                }\n            } else {\n                branchPatterns = List.of();\n            }\n\n            var includeTags = branchPatterns.isEmpty() && refspecs.isEmpty();\n            var onlyTags = false;\n            if (repo.contains(\"tags\")) {\n                var tags = repo.get(\"tags\").asString().toLowerCase().strip();\n                if (!Set.of(\"include\", \"only\").contains(tags)) {\n                    throw new IllegalStateException(\"\\\"tags\\\" field can only have value \\\"include\\\" or \\\"only\\\"\");\n                }\n                onlyTags = tags.equals(\"only\");\n                includeTags = tags.equals(\"include\");\n            }\n            if (onlyTags) {\n                // Tags are by definition included when only tags are mirrored\n                includeTags = true;\n            }\n            if (onlyTags && !branchPatterns.isEmpty()) {\n                throw new IllegalStateException(\"Branches cannot be mirrored when only tags are mirrored\");\n            }\n            if ((onlyTags || includeTags) && !refspecs.isEmpty()) {\n                throw new IllegalStateException(\"Cannot combine refspecs and tags\");\n            }\n\n            log.info(\"Setting up mirroring from \" + fromRepo.name() + \" to \" + toRepo.name());\n            bots.add(new MirrorBot(storage, fromRepo, toRepo, branchPatterns, includeTags, onlyTags, refspecs));\n        }\n        return bots;\n    }\n}\n"
  },
  {
    "path": "bots/mirror/src/test/java/org/openjdk/skara/bots/mirror/MirrorBotFactoryTest.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mirror;\n\nimport java.util.List;\nimport java.util.regex.Pattern;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.json.JWCC;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestBotFactory;\nimport org.openjdk.skara.test.TestHostedRepository;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass MirrorBotFactoryTest {\n    @Test\n    public void testCreate() {\n        try (var tempFolder = new TemporaryDirectory()) {\n            String jsonString = \"\"\"\n                    {\n                      \"repositories\": [\n                        {\n                          \"from\": \"from1\",\n                          \"to\": \"to1\",\n                          \"branches\": \"master\"\n                        },\n                        {\n                          \"from\": \"from2\",\n                          \"to\": \"to2\",\n                          \"branches\": [\n                            \"master\",\n                            \"dev\",\n                            \"test\"\n                          ]\n                        },\n                        {\n                          \"from\": \"from3\",\n                          \"to\": \"to3\"\n                        },\n                      ]\n                    }\n                    \"\"\";\n            var jsonConfig = JWCC.parse(jsonString).asObject();\n\n            var testBotFactory = TestBotFactory.newBuilder()\n                    .addHostedRepository(\"from1\", new TestHostedRepository(\"from1\"))\n                    .addHostedRepository(\"from2\", new TestHostedRepository(\"from2\"))\n                    .addHostedRepository(\"from3\", new TestHostedRepository(\"from3\"))\n                    .addHostedRepository(\"to1\", new TestHostedRepository(\"to1\"))\n                    .addHostedRepository(\"to2\", new TestHostedRepository(\"to2\"))\n                    .addHostedRepository(\"to3\", new TestHostedRepository(\"to3\"))\n                    .storagePath(tempFolder.path().resolve(\"storage\"))\n                    .build();\n\n            var bots = testBotFactory.createBots(MirrorBotFactory.NAME, jsonConfig);\n            assertEquals(3, bots.size());\n\n            MirrorBot mirrorBot1 = (MirrorBot) bots.get(0);\n            assertEquals(\"MirrorBot@from1->to1 (master) [tags excluded]\", mirrorBot1.toString());\n            assertFalse(mirrorBot1.isIncludeTags());\n            assertFalse(mirrorBot1.isOnlyTags());\n            assertEquals(\"master\", mirrorBot1.getBranchPatterns().get(0).toString());\n\n            MirrorBot mirrorBot2 = (MirrorBot) bots.get(1);\n            assertEquals(\"MirrorBot@from2->to2 (master,dev,test) [tags excluded]\", mirrorBot2.toString());\n            assertFalse(mirrorBot2.isIncludeTags());\n            assertFalse(mirrorBot2.isOnlyTags());\n            assertEquals(\"master\", mirrorBot2.getBranchPatterns().get(0).toString());\n            assertEquals(\"dev\", mirrorBot2.getBranchPatterns().get(1).toString());\n            assertEquals(\"test\", mirrorBot2.getBranchPatterns().get(2).toString());\n\n            MirrorBot mirrorBot3 = (MirrorBot) bots.get(2);\n            assertEquals(\"MirrorBot@from3->to3 (*) [tags included]\", mirrorBot3.toString());\n            assertTrue(mirrorBot3.isIncludeTags());\n            assertFalse(mirrorBot3.isOnlyTags());\n            assertEquals(0, mirrorBot3.getBranchPatterns().size());\n        }\n    }\n\n    @Test\n    public void testThrowsWithUnsupportedTagsValue() {\n        try (var tempFolder = new TemporaryDirectory()) {\n            String jsonString = \"\"\"\n                    {\n                      \"repositories\": [\n                        {\n                          \"from\": \"from1\",\n                          \"to\": \"to1\",\n                          \"branches\": \"master\",\n                          \"tags\": \"foo\"\n                        }\n                      ]\n                    }\n                    \"\"\";\n            var jsonConfig = JWCC.parse(jsonString).asObject();\n\n            var testBotFactory = TestBotFactory.newBuilder()\n                    .addHostedRepository(\"from1\", new TestHostedRepository(\"from1\"))\n                    .addHostedRepository(\"to1\", new TestHostedRepository(\"to1\"))\n                    .storagePath(tempFolder.path().resolve(\"storage\"))\n                    .build();\n\n            assertThrows(IllegalStateException.class, () -> testBotFactory.createBots(MirrorBotFactory.NAME, jsonConfig));\n        }\n    }\n\n    @Test\n    public void testThrowsWithBranchesAndTagsOnly() {\n        try (var tempFolder = new TemporaryDirectory()) {\n            String jsonString = \"\"\"\n                    {\n                      \"repositories\": [\n                        {\n                          \"from\": \"from1\",\n                          \"to\": \"to1\",\n                          \"branches\": \"master\",\n                          \"tags\": \"only\"\n                        }\n                      ]\n                    }\n                    \"\"\";\n            var jsonConfig = JWCC.parse(jsonString).asObject();\n\n            var testBotFactory = TestBotFactory.newBuilder()\n                    .addHostedRepository(\"from1\", new TestHostedRepository(\"from1\"))\n                    .addHostedRepository(\"to1\", new TestHostedRepository(\"to1\"))\n                    .storagePath(tempFolder.path().resolve(\"storage\"))\n                    .build();\n\n            assertThrows(IllegalStateException.class, () -> testBotFactory.createBots(MirrorBotFactory.NAME, jsonConfig));\n        }\n    }\n\n    @Test\n    public void testCreateWithTags() {\n        try (var tempFolder = new TemporaryDirectory()) {\n            String jsonString = \"\"\"\n                    {\n                      \"repositories\": [\n                        {\n                          \"from\": \"from1\",\n                          \"to\": \"to1\",\n                          \"branches\": \"master\"\n                        },\n                        {\n                          \"from\": \"from2\",\n                          \"to\": \"to2\",\n                          \"tags\": \"include\"\n                        },\n                        {\n                          \"from\": \"from3\",\n                          \"to\": \"to3\",\n                        },\n                        {\n                          \"from\": \"from4\",\n                          \"to\": \"to4\",\n                          \"tags\": \"only\"\n                        },\n                        {\n                          \"from\": \"from5\",\n                          \"to\": \"to5\",\n                          \"branches\": [\"master\", \"dev\"]\n                        },\n                        {\n                          \"from\": \"from6\",\n                          \"to\": \"to6\",\n                          \"branches\": [\"master\", \"dev\"],\n                          \"tags\": \"include\"\n                        },\n                      ]\n                    }\n                    \"\"\";\n            var jsonConfig = JWCC.parse(jsonString).asObject();\n\n            var testBotFactory = TestBotFactory.newBuilder()\n                    .addHostedRepository(\"from1\", new TestHostedRepository(\"from1\"))\n                    .addHostedRepository(\"from2\", new TestHostedRepository(\"from2\"))\n                    .addHostedRepository(\"from3\", new TestHostedRepository(\"from3\"))\n                    .addHostedRepository(\"from4\", new TestHostedRepository(\"from4\"))\n                    .addHostedRepository(\"from5\", new TestHostedRepository(\"from5\"))\n                    .addHostedRepository(\"from6\", new TestHostedRepository(\"from6\"))\n                    .addHostedRepository(\"to1\", new TestHostedRepository(\"to1\"))\n                    .addHostedRepository(\"to2\", new TestHostedRepository(\"to2\"))\n                    .addHostedRepository(\"to3\", new TestHostedRepository(\"to3\"))\n                    .addHostedRepository(\"to4\", new TestHostedRepository(\"to4\"))\n                    .addHostedRepository(\"to5\", new TestHostedRepository(\"to5\"))\n                    .addHostedRepository(\"to6\", new TestHostedRepository(\"to6\"))\n                    .storagePath(tempFolder.path().resolve(\"storage\"))\n                    .build();\n\n            var bots = testBotFactory.createBots(MirrorBotFactory.NAME, jsonConfig);\n            assertEquals(6, bots.size());\n\n            MirrorBot mirrorBot1 = (MirrorBot) bots.get(0);\n            assertEquals(\"MirrorBot@from1->to1 (master) [tags excluded]\", mirrorBot1.toString());\n            assertFalse(mirrorBot1.isIncludeTags());\n            assertFalse(mirrorBot1.isOnlyTags());\n            assertEquals(List.of(\"master\"),\n                         mirrorBot1.getBranchPatterns().stream().map(Pattern::toString).toList());\n\n            MirrorBot mirrorBot2 = (MirrorBot) bots.get(1);\n            assertEquals(\"MirrorBot@from2->to2 (*) [tags included]\", mirrorBot2.toString());\n            assertTrue(mirrorBot2.isIncludeTags());\n            assertFalse(mirrorBot2.isOnlyTags());\n            assertEquals(List.of(), mirrorBot2.getBranchPatterns());\n\n            MirrorBot mirrorBot3 = (MirrorBot) bots.get(2);\n            assertEquals(\"MirrorBot@from3->to3 (*) [tags included]\", mirrorBot3.toString());\n            assertTrue(mirrorBot3.isIncludeTags());\n            assertFalse(mirrorBot3.isOnlyTags());\n            assertEquals(List.of(), mirrorBot3.getBranchPatterns());\n\n            MirrorBot mirrorBot4 = (MirrorBot) bots.get(3);\n            assertEquals(\"MirrorBot@from4->to4 () [tags only]\", mirrorBot4.toString());\n            assertTrue(mirrorBot4.isIncludeTags());\n            assertTrue(mirrorBot4.isOnlyTags());\n            assertEquals(List.of(), mirrorBot4.getBranchPatterns());\n\n            MirrorBot mirrorBot5 = (MirrorBot) bots.get(4);\n            assertEquals(\"MirrorBot@from5->to5 (master,dev) [tags excluded]\", mirrorBot5.toString());\n            assertFalse(mirrorBot5.isIncludeTags());\n            assertFalse(mirrorBot5.isOnlyTags());\n            assertEquals(List.of(\"master\", \"dev\"),\n                         mirrorBot5.getBranchPatterns().stream().map(Pattern::toString).toList());\n\n            MirrorBot mirrorBot6 = (MirrorBot) bots.get(5);\n            assertEquals(\"MirrorBot@from6->to6 (master,dev) [tags included]\", mirrorBot6.toString());\n            assertTrue(mirrorBot6.isIncludeTags());\n            assertFalse(mirrorBot6.isOnlyTags());\n            assertEquals(List.of(\"master\", \"dev\"),\n                         mirrorBot6.getBranchPatterns().stream().map(Pattern::toString).toList());\n        }\n    }\n\n    @Test\n    public void testThrowsWithRefspecsAndTags() {\n        try (var tempFolder = new TemporaryDirectory()) {\n            String jsonString = \"\"\"\n                    {\n                      \"repositories\": [\n                        {\n                          \"from\": \"from1\",\n                          \"to\": \"to1\",\n                          \"refspecs\": \"refs/foo\",\n                          \"tags\": \"only\"\n                        }\n                      ]\n                    }\n                    \"\"\";\n            var jsonConfig = JWCC.parse(jsonString).asObject();\n\n            var testBotFactory = TestBotFactory.newBuilder()\n                    .addHostedRepository(\"from1\", new TestHostedRepository(\"from1\"))\n                    .addHostedRepository(\"to1\", new TestHostedRepository(\"to1\"))\n                    .storagePath(tempFolder.path().resolve(\"storage\"))\n                    .build();\n\n            assertThrows(IllegalStateException.class, () -> testBotFactory.createBots(MirrorBotFactory.NAME, jsonConfig));\n        }\n    }\n\n    @Test\n    public void testThrowsWithRefspecsAndBranches() {\n        try (var tempFolder = new TemporaryDirectory()) {\n            String jsonString = \"\"\"\n                    {\n                      \"repositories\": [\n                        {\n                          \"from\": \"from1\",\n                          \"to\": \"to1\",\n                          \"refspecs\": \"refs/foo\",\n                          \"branches\": \"master\"\n                        }\n                      ]\n                    }\n                    \"\"\";\n            var jsonConfig = JWCC.parse(jsonString).asObject();\n\n            var testBotFactory = TestBotFactory.newBuilder()\n                    .addHostedRepository(\"from1\", new TestHostedRepository(\"from1\"))\n                    .addHostedRepository(\"to1\", new TestHostedRepository(\"to1\"))\n                    .storagePath(tempFolder.path().resolve(\"storage\"))\n                    .build();\n\n            assertThrows(IllegalStateException.class, () -> testBotFactory.createBots(MirrorBotFactory.NAME, jsonConfig));\n        }\n    }\n\n    @Test\n    public void testCreateWithRefspecs() {\n        try (var tempFolder = new TemporaryDirectory()) {\n            String jsonString = \"\"\"\n                    {\n                      \"repositories\": [\n                        {\n                          \"from\": \"from1\",\n                          \"to\": \"to1\",\n                          \"refspecs\": \"refs/foo\",\n                        },\n                        {\n                          \"from\": \"from2\",\n                          \"to\": \"to2\",\n                          \"refspecs\": [\n                            \"refs/foo\",\n                            \"refs/bar\"\n                          ]\n                        }\n                      ]\n                    }\n                    \"\"\";\n            var jsonConfig = JWCC.parse(jsonString).asObject();\n\n            var testBotFactory = TestBotFactory.newBuilder()\n                    .addHostedRepository(\"from1\", new TestHostedRepository(\"from1\"))\n                    .addHostedRepository(\"from2\", new TestHostedRepository(\"from2\"))\n                    .addHostedRepository(\"to1\", new TestHostedRepository(\"to1\"))\n                    .addHostedRepository(\"to2\", new TestHostedRepository(\"to2\"))\n                    .storagePath(tempFolder.path().resolve(\"storage\"))\n                    .build();\n\n            var bots = testBotFactory.createBots(MirrorBotFactory.NAME, jsonConfig);\n            assertEquals(2, bots.size());\n\n            MirrorBot mirrorBot1 = (MirrorBot) bots.get(0);\n            assertEquals(\"MirrorBot@from1->to1 (refs/foo)\", mirrorBot1.toString());\n            assertFalse(mirrorBot1.isIncludeTags());\n            assertFalse(mirrorBot1.isOnlyTags());\n            assertEquals(List.of(), mirrorBot1.getBranchPatterns());\n            assertEquals(List.of(\"refs/foo\"), mirrorBot1.getRefspecs());\n\n            MirrorBot mirrorBot2 = (MirrorBot) bots.get(1);\n            assertEquals(\"MirrorBot@from2->to2 (refs/foo,refs/bar)\", mirrorBot2.toString());\n            assertFalse(mirrorBot2.isIncludeTags());\n            assertFalse(mirrorBot2.isOnlyTags());\n            assertEquals(List.of(), mirrorBot2.getBranchPatterns());\n            assertEquals(List.of(\"refs/foo\", \"refs/bar\"), mirrorBot2.getRefspecs());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/mirror/src/test/java/org/openjdk/skara/bots/mirror/MirrorBotTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mirror;\n\nimport java.net.URLEncoder;\nimport java.nio.charset.StandardCharsets;\nimport java.util.regex.Pattern;\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.*;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.StandardOpenOption;\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass MirrorBotTests {\n    @Test\n    void mirrorMasterBranch(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var gitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var newFile = fromDir.resolve(\"this-file-cannot-exist.txt\");\n            Files.writeString(newFile, \"Hello world\\n\");\n            fromLocalRepo.add(newFile);\n            var newHash = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(newHash, fromCommits.get(0).hash());\n\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(0, toCommits.size());\n\n            var storage = temp.path().resolve(\"storage\");\n            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo);\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(newHash, toCommits.get(0).hash());\n        }\n    }\n\n    @Test\n    void mirrorMultipleBranches(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var gitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var newFile = fromDir.resolve(\"this-file-cannot-exist.txt\");\n            Files.writeString(newFile, \"Hello world\\n\");\n            fromLocalRepo.add(newFile);\n            var newHash = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(newHash, fromCommits.get(0).hash());\n\n            fromLocalRepo.branch(newHash, \"second\");\n            fromLocalRepo.branch(newHash, \"third\");\n\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(0, toCommits.size());\n            assertEquals(0, toLocalRepo.branches().size());\n\n            var storage = temp.path().resolve(\"storage\");\n            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo);\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(newHash, toCommits.get(0).hash());\n            var toBranches = toLocalRepo.branches();\n            assertEquals(3, toBranches.size());\n            assertTrue(toBranches.contains(new Branch(\"master\")));\n            assertTrue(toBranches.contains(new Branch(\"second\")));\n            assertTrue(toBranches.contains(new Branch(\"third\")));\n        }\n    }\n\n    /**\n     * Tests mirrorEverything with multiple tags\n     */\n    @Test\n    void mirrorEverythingMultipleTags(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var gitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var newFile = fromDir.resolve(\"this-file-cannot-exist.txt\");\n            Files.writeString(newFile, \"Hello world\\n\");\n            fromLocalRepo.add(newFile);\n            var newHash = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(newHash, fromCommits.get(0).hash());\n\n            fromLocalRepo.tag(newHash, \"first\", \"add first tag\", \"duke\", \"duk@openjdk.org\");\n            fromLocalRepo.tag(newHash, \"second\", \"add second tag\", \"duke\", \"duk@openjdk.org\");\n\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(0, toCommits.size());\n            assertEquals(0, toLocalRepo.tags().size());\n\n            var storage = temp.path().resolve(\"storage\");\n            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo);\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(newHash, toCommits.get(0).hash());\n            var toTags = toLocalRepo.tags();\n            assertEquals(2, toTags.size());\n            assertTrue(toTags.contains(new Tag(\"first\")));\n            assertTrue(toTags.contains(new Tag(\"second\")));\n\n            // Add another tag and go again\n            fromLocalRepo.tag(newHash, \"third\", \"add third tag\", \"duke\", \"duk@openjdk.org\");\n\n            TestBotRunner.runPeriodicItems(bot);\n            toTags = toLocalRepo.tags();\n            assertEquals(3, toTags.size());\n            assertTrue(toTags.contains(new Tag(\"first\")));\n            assertTrue(toTags.contains(new Tag(\"second\")));\n            assertTrue(toTags.contains(new Tag(\"third\")));\n        }\n    }\n\n    /**\n     * Tests mirroring a single branch, including tags\n     */\n    @Test\n    void mirrorSingleBranchAndTags(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var gitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                    StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var newFile = fromDir.resolve(\"this-file-cannot-exist.txt\");\n            Files.writeString(newFile, \"Hello world\\n\");\n            fromLocalRepo.add(newFile);\n            var newHash = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(newHash, fromCommits.get(0).hash());\n\n            fromLocalRepo.tag(newHash, \"first\", \"add first tag\", \"duke\", \"duk@openjdk.org\");\n            fromLocalRepo.tag(newHash, \"second\", \"add second tag\", \"duke\", \"duk@openjdk.org\");\n\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(0, toCommits.size());\n            assertEquals(0, toLocalRepo.tags().size());\n\n            var storage = temp.path().resolve(\"storage\");\n            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(Pattern.compile(\"master\")), true, false, List.of());\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(newHash, toCommits.get(0).hash());\n            var toTags = toLocalRepo.tags();\n            assertEquals(2, toTags.size());\n            assertTrue(toTags.contains(new Tag(\"first\")));\n            assertTrue(toTags.contains(new Tag(\"second\")));\n\n            // Add another tag and go again\n            fromLocalRepo.tag(newHash, \"third\", \"add third tag\", \"duke\", \"duk@openjdk.org\");\n\n            TestBotRunner.runPeriodicItems(bot);\n            toTags = toLocalRepo.tags();\n            assertEquals(3, toTags.size());\n            assertTrue(toTags.contains(new Tag(\"first\")));\n            assertTrue(toTags.contains(new Tag(\"second\")));\n            assertTrue(toTags.contains(new Tag(\"third\")));\n\n            // Change a tag and go again\n            Files.writeString(newFile, \"Hello world again\\n\", StandardOpenOption.APPEND);\n            fromLocalRepo.add(newFile);\n            var secondHash = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var firstTag = fromLocalRepo.tag(secondHash, \"first\", \"add first tag again\", \"duke\", \"duk@openjdk.org\", null, true);\n\n            TestBotRunner.runPeriodicItems(bot);\n            toTags = toLocalRepo.tags();\n            assertEquals(3, toTags.size());\n            assertEquals(fromLocalRepo.annotate(firstTag), toLocalRepo.annotate(firstTag), \"First tag not correctly mirrored\");\n        }\n    }\n\n    /**\n     * Tests mirroring a single branch without including tags\n     */\n    @Test\n    void mirrorSingleBranchNoTags(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var gitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                    StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var newFile = fromDir.resolve(\"this-file-cannot-exist.txt\");\n            Files.writeString(newFile, \"Hello world\\n\");\n            fromLocalRepo.add(newFile);\n            var newHash = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(newHash, fromCommits.get(0).hash());\n\n            fromLocalRepo.tag(newHash, \"first\", \"add first tag\", \"duke\", \"duk@openjdk.org\");\n\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(0, toCommits.size());\n            assertEquals(0, toLocalRepo.tags().size());\n\n            var storage = temp.path().resolve(\"storage\");\n            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(Pattern.compile(\"master\")), false, false, List.of());\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(newHash, toCommits.get(0).hash());\n            var toTags = toLocalRepo.tags();\n            assertEquals(0, toTags.size());\n\n            // Go a second time\n            TestBotRunner.runPeriodicItems(bot);\n            toTags = toLocalRepo.tags();\n            assertEquals(0, toTags.size());\n        }\n    }\n\n    @Test\n    void mirrorRemovingBranch(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var gitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var newFile = fromDir.resolve(\"this-file-cannot-exist.txt\");\n            Files.writeString(newFile, \"Hello world\\n\");\n            fromLocalRepo.add(newFile);\n            var newHash = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(newHash, fromCommits.get(0).hash());\n\n            fromLocalRepo.branch(newHash, \"second\");\n            fromLocalRepo.branch(newHash, \"third\");\n\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(0, toCommits.size());\n            assertEquals(0, toLocalRepo.branches().size());\n\n            var storage = temp.path().resolve(\"storage\");\n            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo);\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(newHash, toCommits.get(0).hash());\n            var toBranches = toLocalRepo.branches();\n            assertEquals(3, toBranches.size());\n            assertTrue(toBranches.contains(new Branch(\"master\")));\n            assertTrue(toBranches.contains(new Branch(\"second\")));\n            assertTrue(toBranches.contains(new Branch(\"third\")));\n\n            fromLocalRepo.delete(new Branch(\"second\"));\n            assertEquals(2, fromLocalRepo.branches().size());\n\n            TestBotRunner.runPeriodicItems(bot);\n            toBranches = toLocalRepo.branches();\n            assertEquals(2, toBranches.size());\n            assertTrue(toBranches.contains(new Branch(\"master\")));\n            assertTrue(toBranches.contains(new Branch(\"third\")));\n        }\n    }\n\n    @Test\n    void mirrorSelectedBranches(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var gitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var newFile = fromDir.resolve(\"this-file-cannot-exist.txt\");\n            Files.writeString(newFile, \"Hello world\\n\");\n            fromLocalRepo.add(newFile);\n            var first = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var featureBranch = fromLocalRepo.branch(first, \"feature\");\n            fromLocalRepo.checkout(featureBranch, false);\n            assertEquals(Optional.of(featureBranch), fromLocalRepo.currentBranch());\n\n            Files.writeString(newFile, \"Hello again\\n\", StandardOpenOption.APPEND);\n            fromLocalRepo.add(newFile);\n            var second = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n\n            assertEquals(Optional.of(first), fromLocalRepo.resolve(\"master\"));\n            assertEquals(Optional.of(second), fromLocalRepo.resolve(\"feature\"));\n\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(2, fromCommits.size());\n\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(0, toCommits.size());\n\n            var storage = temp.path().resolve(\"storage\");\n            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(Pattern.compile(\"master\")), false, false, List.of());\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(first, toCommits.get(0).hash());\n            assertEquals(List.of(new Branch(\"master\")), toLocalRepo.branches());\n        }\n    }\n\n    @Test\n    void mirrorSelectedBranchPattern(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var gitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                    StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var newFile = fromDir.resolve(\"this-file-cannot-exist.txt\");\n            Files.writeString(newFile, \"Hello world\\n\");\n            fromLocalRepo.add(newFile);\n            var first = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var featureBranch = fromLocalRepo.branch(first, \"feature\");\n            fromLocalRepo.checkout(featureBranch, false);\n            assertEquals(Optional.of(featureBranch), fromLocalRepo.currentBranch());\n\n            Files.writeString(newFile, \"Hello again\\n\", StandardOpenOption.APPEND);\n            fromLocalRepo.add(newFile);\n            var second = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n\n            assertEquals(Optional.of(first), fromLocalRepo.resolve(\"master\"));\n            assertEquals(Optional.of(second), fromLocalRepo.resolve(\"feature\"));\n\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(2, fromCommits.size());\n\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(0, toCommits.size());\n\n            var storage = temp.path().resolve(\"storage\");\n            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(Pattern.compile(\"f.*\")), false, false, List.of());\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(2, toCommits.size());\n            assertEquals(second, toCommits.get(0).hash());\n            assertEquals(List.of(new Branch(\"feature\")), toLocalRepo.branches());\n        }\n    }\n\n    @Test\n    void mirrorMasterBranchWithExistingCloneDirectory(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var gitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                    StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var newFile = fromDir.resolve(\"this-file-cannot-exist.txt\");\n            Files.writeString(newFile, \"Hello world\\n\");\n            fromLocalRepo.add(newFile);\n            var newHash = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(newHash, fromCommits.get(0).hash());\n\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(0, toCommits.size());\n\n            var storage = temp.path().resolve(\"storage\");\n            var sanitizedUrl =\n                    URLEncoder.encode(toHostedRepo.webUrl().toString(), StandardCharsets.UTF_8);\n            var temporaryDir = storage.resolve(sanitizedUrl);\n            Files.createDirectories(temporaryDir);\n            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo);\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(newHash, toCommits.get(0).hash());\n        }\n    }\n\n    /**\n     * Tests mirroring only tags\n     */\n    @Test\n    void mirrorOnlyTags(TestInfo testInfo) throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var gitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                    StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var newFile = fromDir.resolve(\"this-file-cannot-exist.txt\");\n            Files.writeString(newFile, \"Hello world\\n\");\n            fromLocalRepo.add(newFile);\n            var newHash = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(newHash, fromCommits.get(0).hash());\n\n            fromLocalRepo.tag(newHash, \"first\", \"add first tag\", \"duke\", \"duk@openjdk.org\");\n            fromLocalRepo.tag(newHash, \"second\", \"add second tag\", \"duke\", \"duk@openjdk.org\");\n\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(0, toCommits.size());\n            assertEquals(0, toLocalRepo.tags().size());\n            assertEquals(0, toLocalRepo.branches().size());\n\n            var storage = temp.path().resolve(\"storage\");\n            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(), true, true, List.of());\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(newHash, toCommits.get(0).hash());\n            var toTags = toLocalRepo.tags();\n            assertEquals(2, toTags.size());\n            assertTrue(toTags.contains(new Tag(\"first\")));\n            assertTrue(toTags.contains(new Tag(\"second\")));\n            assertEquals(0, toLocalRepo.branches().size());\n\n            // Add another tag and go again\n            fromLocalRepo.tag(newHash, \"third\", \"add third tag\", \"duke\", \"duk@openjdk.org\");\n\n            TestBotRunner.runPeriodicItems(bot);\n            toTags = toLocalRepo.tags();\n            assertEquals(3, toTags.size());\n            assertTrue(toTags.contains(new Tag(\"first\")));\n            assertTrue(toTags.contains(new Tag(\"second\")));\n            assertTrue(toTags.contains(new Tag(\"third\")));\n            assertEquals(0, toLocalRepo.branches().size());\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n\n            // Change a tag and go again\n            Files.writeString(newFile, \"Hello world again\\n\", StandardOpenOption.APPEND);\n            fromLocalRepo.add(newFile);\n            var secondHash = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var firstTag = fromLocalRepo.tag(secondHash, \"first\", \"add first tag again\", \"duke\", \"duk@openjdk.org\", null, true);\n\n            TestBotRunner.runPeriodicItems(bot);\n            assertEquals(0, toLocalRepo.branches().size());\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(2, toCommits.size());\n            toTags = toLocalRepo.tags();\n            assertEquals(3, toTags.size());\n            assertEquals(fromLocalRepo.annotate(firstTag), toLocalRepo.annotate(firstTag), \"First tag not correctly mirrored\");\n        }\n    }\n\n    @Test\n    void mirrorRefspecs() throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var gitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                    StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var newFile = fromDir.resolve(\"this-file-cannot-exist.txt\");\n            Files.writeString(newFile, \"Hello world\\n\");\n            fromLocalRepo.add(newFile);\n            var newHash = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(1, fromCommits.size());\n            assertEquals(newHash, fromCommits.get(0).hash());\n\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(0, toCommits.size());\n\n            var storage = temp.path().resolve(\"storage\");\n            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(), false, false,\n                    List.of(\"refs/heads/master:refs/heads/master\"));\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(1, toCommits.size());\n            assertEquals(newHash, toCommits.get(0).hash());\n        }\n    }\n\n    @Test\n    void mirrorMultipleRefspecs() throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var fromLocalRepo = TestableRepository.init(fromDir, VCS.GIT);\n            var fromHostedRepo = new TestHostedRepository(host, \"test\", fromLocalRepo);\n\n            var toDir = temp.path().resolve(\"to.git\");\n            var toLocalRepo = TestableRepository.init(toDir, VCS.GIT);\n            var gitConfig = toDir.resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                    StandardOpenOption.APPEND);\n            var toHostedRepo = new TestHostedRepository(host, \"test-mirror\", toLocalRepo);\n\n            var newFile = fromDir.resolve(\"this-file-cannot-exist.txt\");\n            Files.writeString(newFile, \"Hello world\\n\");\n            fromLocalRepo.add(newFile);\n            var first = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            var featureBranch = fromLocalRepo.branch(first, \"feature\");\n            fromLocalRepo.checkout(featureBranch, false);\n            assertEquals(Optional.of(featureBranch), fromLocalRepo.currentBranch());\n\n            Files.writeString(newFile, \"Hello again\\n\", StandardOpenOption.APPEND);\n            fromLocalRepo.add(newFile);\n            var second = fromLocalRepo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n\n            assertEquals(Optional.of(first), fromLocalRepo.resolve(\"master\"));\n            assertEquals(Optional.of(second), fromLocalRepo.resolve(\"feature\"));\n\n            fromLocalRepo.tag(first, \"firstTag\", \"add first tag\", \"duke\", \"duk@openjdk.org\");\n            fromLocalRepo.tag(second, \"secondTag\", \"add second tag\", \"duke\", \"duk@openjdk.org\");\n\n            var fromCommits = fromLocalRepo.commits().asList();\n            assertEquals(2, fromCommits.size());\n\n            var toCommits = toLocalRepo.commits().asList();\n            assertEquals(0, toCommits.size());\n\n            var storage = temp.path().resolve(\"storage\");\n            var bot = new MirrorBot(storage, fromHostedRepo, toHostedRepo, List.of(), false, false,\n                    List.of(\"refs/heads/m*:refs/heads/m*\", \"refs/tags/s*:refs/tags/s*\"));\n            TestBotRunner.runPeriodicItems(bot);\n\n            toCommits = toLocalRepo.commits().asList();\n            assertEquals(2, toCommits.size());\n            assertEquals(second, toCommits.get(0).hash());\n            assertEquals(List.of(new Branch(\"master\")), toLocalRepo.branches());\n            assertEquals(List.of(new Tag(\"secondTag\")), toLocalRepo.tags());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.mlbridge'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.bots.mlbridge' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':ci')\n    implementation project(':bot')\n    implementation project(':mailinglist')\n    implementation project(':host')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':network')\n    implementation project(':census')\n    implementation project(':vcs')\n    implementation project(':jcheck')\n    implementation project(':json')\n    implementation project(':email')\n    implementation project(':webrev')\n    implementation project(':version')\n    implementation project(':metrics')\n    implementation project(':bots:common')\n    implementation project(':jbs')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.bots.mlbridge {\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.mailinglist;\n    requires org.openjdk.skara.host;\n    requires org.openjdk.skara.census;\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.jcheck;\n    requires org.openjdk.skara.json;\n    requires org.openjdk.skara.email;\n    requires org.openjdk.skara.webrev;\n    requires org.openjdk.skara.network;\n    requires org.openjdk.skara.version;\n    requires org.openjdk.skara.jbs;\n    requires org.openjdk.skara.bots.common;\n    requires java.logging;\n    requires java.net.http;\n\n    provides org.openjdk.skara.bot.BotFactory with org.openjdk.skara.bots.mlbridge.MailingListBridgeBotFactory;\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/ArchiveItem.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.function.Supplier;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\nclass ArchiveItem {\n    private final String id;\n    private final ZonedDateTime created;\n    private final ZonedDateTime updated;\n    private final HostUser author;\n    private final Map<String, String> extraHeaders;\n    private final ArchiveItem parent;\n    private final Supplier<String> subject;\n    private final Supplier<String> header;\n    private String resolvedHeader;\n    private final Supplier<String> body;\n    private String resolvedBody;\n    private final Supplier<String> footer;\n    private String resolvedFooter;\n\n    private ArchiveItem(ArchiveItem parent, String id, ZonedDateTime created, ZonedDateTime updated, HostUser author, Map<String, String> extraHeaders, Supplier<String> subject, Supplier<String> header, Supplier<String> body, Supplier<String> footer) {\n        this.id = id;\n        this.created = created;\n        this.updated = updated;\n        this.author = author;\n        this.extraHeaders = extraHeaders;\n        this.parent = parent;\n        this.subject = subject;\n        this.header = header;\n        this.body = body;\n        this.footer = footer;\n    }\n\n    private static Optional<Commit> mergeCommit(PullRequest pr, Repository localRepo, Hash head) {\n        try {\n            var author = new Author(\"duke\", \"duke@openjdk.org\");\n            var hash = PullRequestUtils.createCommit(pr, localRepo, head, author, author, pr.title());\n            return localRepo.lookup(hash);\n        } catch (IOException | CommitFailure e) {\n            return Optional.empty();\n        }\n    }\n\n    private static Optional<Commit> conflictCommit(PullRequest pr, Repository localRepo, Hash head) {\n        try {\n            localRepo.checkout(head, true);\n        } catch (IOException e) {\n            return Optional.empty();\n        }\n\n        try {\n            localRepo.merge(PullRequestUtils.targetHash(localRepo));\n            // No problem means no conflict\n            return Optional.empty();\n        } catch (IOException e) {\n            try {\n                var status = localRepo.status();\n                var unmerged = status.stream()\n                                     .filter(entry -> entry.status().isUnmerged())\n                                     .map(entry -> entry.source().path())\n                                     .filter(Optional::isPresent)\n                                     .map(Optional::get)\n                                     .collect(Collectors.toList());\n\n                // Drop the successful merges from the stage\n                localRepo.reset(head, false);\n                // Add the unmerged files as-is (retaining the conflict markers)\n                localRepo.add(unmerged);\n                var hash = localRepo.commit(\"Conflicts in \" + pr.title(), \"duke\", \"duke@openjdk.org\");\n                localRepo.clean();\n                return localRepo.lookup(hash);\n            } catch (IOException ioException) {\n                return Optional.empty();\n            }\n        }\n    }\n\n    static ArchiveItem from(PullRequest pr, Repository localRepo, HostUserToEmailAuthor hostUserToEmailAuthor,\n                            URI issueTracker, String issuePrefix, WebrevStorage.WebrevGenerator webrevGenerator,\n                            WebrevNotification webrevNotification, ZonedDateTime created, ZonedDateTime updated,\n                            Hash base, Hash head, String subjectPrefix, String threadPrefix) {\n        return new ArchiveItem(null, \"fc\", created, updated, pr.author(), Map.of(\"PR-Head-Hash\", head.hex(),\n                                                                                 \"PR-Base-Hash\", base.hex(),\n                                                                                 \"PR-Thread-Prefix\", threadPrefix),\n                               () -> subjectPrefix + threadPrefix + (threadPrefix.isEmpty() ? \"\" : \": \") + pr.title(),\n                               () -> \"\",\n                               () -> ArchiveMessages.composeConversation(pr),\n                               () -> {\n                                   if (PullRequestUtils.isMerge(pr)) {\n                                       var mergeWebrevs = new ArrayList<WebrevDescription>();\n                                       var conflictCommit = conflictCommit(pr, localRepo, head);\n                                       conflictCommit.ifPresent(commit -> mergeWebrevs.add(\n                                               webrevGenerator.generate(commit.parentDiffs().get(0), \"00.conflicts\", WebrevDescription.Type.MERGE_CONFLICT, pr.targetRef())));\n                                       var mergeCommit = mergeCommit(pr, localRepo, head);\n                                       if (mergeCommit.isPresent()) {\n                                           for (int i = 0; i < mergeCommit.get().parentDiffs().size(); ++i) {\n                                               var diff = mergeCommit.get().parentDiffs().get(i);\n                                               if (diff.patches().size() == 0) {\n                                                   continue;\n                                               }\n                                               switch (i) {\n                                                   case 0:\n                                                       mergeWebrevs.add(webrevGenerator.generate(diff, String.format(\"00.%d\", i), WebrevDescription.Type.MERGE_TARGET, pr.targetRef()));\n                                                       break;\n                                                   case 1:\n                                                       var mergeSource = pr.title().length() > 6 ? pr.title().substring(6) : null;\n                                                       mergeWebrevs.add(webrevGenerator.generate(diff, String.format(\"00.%d\", i), WebrevDescription.Type.MERGE_SOURCE, mergeSource));\n                                                       break;\n                                                   default:\n                                                       mergeWebrevs.add(webrevGenerator.generate(diff, String.format(\"00.%d\", i), WebrevDescription.Type.MERGE_SOURCE, null));\n                                                       break;\n                                               }\n                                           }\n                                           if (!mergeWebrevs.isEmpty()) {\n                                               webrevNotification.notify(0, mergeWebrevs);\n                                           }\n                                       }\n                                       return ArchiveMessages.composeMergeConversationFooter(pr, localRepo, mergeWebrevs, base, head);\n                                   } else {\n                                       var fullWebrev = webrevGenerator.generate(base, head, \"00\", WebrevDescription.Type.FULL);\n                                       webrevNotification.notify(0, List.of(fullWebrev));\n                                       return ArchiveMessages.composeConversationFooter(pr, issueTracker, issuePrefix, localRepo, fullWebrev, base, head);\n                                   }\n                               });\n    }\n\n    private static Optional<Hash> rebasedLastHead(Repository localRepo, Hash newBase, Hash lastHead) {\n        try {\n            localRepo.checkout(lastHead, true);\n            localRepo.rebase(newBase, \"duke\", \"duke@openjdk.org\");\n            var rebasedLastHead = localRepo.head();\n            return Optional.of(rebasedLastHead);\n        } catch (IOException e) {\n            return Optional.empty();\n        }\n    }\n\n    /**\n     * Checks if lastHead is available in the local repository and tried to fetch it\n     * if not.\n     */\n    private static boolean lastHeadAvailable(PullRequest pr, Repository localRepo, Hash lastHead, boolean tryFetch) {\n        try {\n            if (localRepo.resolve(lastHead.hex()).isPresent()) {\n                return true;\n            }\n            if (tryFetch) {\n                return localRepo.fetch(pr.repository().authenticatedUrl(), lastHead.hex(), false).isPresent();\n            }\n        } catch (IOException e) {\n            return false;\n        }\n        return false;\n    }\n\n    private static String hostUserToCommitterName(HostUserToEmailAuthor hostUserToEmailAuthor, HostUser hostUser) {\n        var email = hostUserToEmailAuthor.author(hostUser);\n        if (email.fullName().isPresent()) {\n            return email.fullName().get();\n        } else {\n            return hostUser.fullName();\n        }\n    }\n\n    static ArchiveItem from(PullRequest pr, Repository localRepo, HostUserToEmailAuthor hostUserToEmailAuthor,\n                            WebrevStorage.WebrevGenerator webrevGenerator, WebrevNotification webrevNotification,\n                            ZonedDateTime created, ZonedDateTime updated, Hash lastBase, Hash lastHead, Hash base,\n                            Hash head, int index, ArchiveItem parent, String subjectPrefix, String threadPrefix) {\n        return new ArchiveItem(parent, \"ha\" + head.hex(), created, updated, pr.author(), Map.of(\"PR-Head-Hash\", head.hex(), \"PR-Base-Hash\", base.hex()),\n                               () -> String.format(\"Re: %s%s%s [v%d]\", subjectPrefix, threadPrefix + (threadPrefix.isEmpty() ? \"\" : \": \"), pr.title(), index + 1),\n                               () -> \"\",\n                               () -> {\n                                   if (lastBase.equals(base)) {\n                                       // Make sure lastHead is present in the local repo (if possible)\n                                       lastHeadAvailable(pr, localRepo, lastHead, true);\n                                       return ArchiveMessages.composeIncrementalRevision(pr, localRepo, hostUserToCommitterName(hostUserToEmailAuthor, pr.author()), head, lastHead, base);\n                                   } else {\n                                       var rebasedLastHead = rebasedLastHead(localRepo, base, lastHead);\n                                       if (rebasedLastHead.isPresent()) {\n                                           return ArchiveMessages.composeRebasedIncrementalRevision(pr, localRepo, hostUserToCommitterName(hostUserToEmailAuthor, pr.author()), head, rebasedLastHead.get());\n                                       } else {\n                                           return ArchiveMessages.composeFullRevision(pr, localRepo, hostUserToCommitterName(hostUserToEmailAuthor, pr.author()), base, head);\n                                       }\n                                   }\n                               },\n                               () -> {\n                                   var fullWebrev = webrevGenerator.generate(base, head, String.format(\"%02d\", index), WebrevDescription.Type.FULL);\n                                   if (lastBase.equals(base)) {\n                                       if (lastHeadAvailable(pr, localRepo, lastHead, false)) {\n                                           var incrementalWebrev = webrevGenerator.generate(lastHead, head, String.format(\"%02d-%02d\", index - 1, index), WebrevDescription.Type.INCREMENTAL);\n                                           webrevNotification.notify(index, List.of(fullWebrev, incrementalWebrev));\n                                           return ArchiveMessages.composeIncrementalFooter(pr, localRepo, fullWebrev, incrementalWebrev, head, lastHead);\n                                       } else {\n                                           webrevNotification.notify(index, List.of(fullWebrev));\n                                           return ArchiveMessages.composeRebasedFooter(pr, localRepo, fullWebrev, base, head);\n                                       }\n                                   } else {\n                                       var rebasedLastHead = rebasedLastHead(localRepo, base, lastHead);\n                                       if (rebasedLastHead.isPresent()) {\n                                           var incrementalWebrev = webrevGenerator.generate(rebasedLastHead.get(), head, String.format(\"%02d-%02d\", index - 1, index), WebrevDescription.Type.INCREMENTAL);\n                                           webrevNotification.notify(index, List.of(fullWebrev, incrementalWebrev));\n                                           return ArchiveMessages.composeIncrementalFooter(pr, localRepo, fullWebrev, incrementalWebrev, head, lastHead);\n                                       } else {\n                                           webrevNotification.notify(index, List.of(fullWebrev));\n                                           return ArchiveMessages.composeRebasedFooter(pr, localRepo, fullWebrev, base, head);\n                                       }\n                                   }\n                               });\n    }\n\n    static ArchiveItem from(PullRequest pr, Comment comment, HostUserToEmailAuthor hostUserToEmailAuthor, ArchiveItem parent) {\n        return new ArchiveItem(parent, \"pc\" + comment.id(), comment.createdAt(), comment.updatedAt(), comment.author(), Map.of(),\n                               () -> ArchiveMessages.composeReplySubject(parent.subject()),\n                               () -> ArchiveMessages.composeReplyHeader(parent.createdAt(), hostUserToEmailAuthor.author(parent.author)),\n                               () -> ArchiveMessages.composeComment(comment),\n                               () -> ArchiveMessages.composeCommentReplyFooter(pr, comment));\n    }\n\n    static ArchiveItem from(PullRequest pr, Review review, HostUserToEmailAuthor hostUserToEmailAuthor, HostUserToUsername hostUserToUsername, HostUserToRole hostUserToRole, ArchiveItem parent) {\n        return new ArchiveItem(parent, \"rv\" + review.id(), review.createdAt(), review.createdAt(), review.reviewer(), Map.of(),\n                               () -> ArchiveMessages.composeReplySubject(parent.subject()),\n                               () -> ArchiveMessages.composeReplyHeader(parent.createdAt(), hostUserToEmailAuthor.author(parent.author())),\n                               () -> ArchiveMessages.composeReview(pr, review, hostUserToUsername, hostUserToRole),\n                               () -> ArchiveMessages.composeReviewFooter(pr, review, hostUserToUsername, hostUserToRole));\n    }\n\n    static ArchiveItem from(PullRequest pr, ReviewComment reviewComment, HostUserToEmailAuthor hostUserToEmailAuthor, ArchiveItem parent) {\n        return new ArchiveItem(parent, \"rc\" + reviewComment.id(), reviewComment.createdAt(), reviewComment.updatedAt(), reviewComment.author(), Map.of(),\n                               () -> ArchiveMessages.composeReplySubject(parent.subject()),\n                               () -> ArchiveMessages.composeReplyHeader(parent.createdAt(), hostUserToEmailAuthor.author(parent.author())),\n                               () -> ArchiveMessages.composeReviewComment(pr, reviewComment),\n                               () -> ArchiveMessages.composeReviewCommentReplyFooter(pr, reviewComment));\n    }\n\n    static ArchiveItem closedNotice(PullRequest pr, HostUserToEmailAuthor hostUserToEmailAuthor, ArchiveItem parent, String subjectPrefix) {\n        var closedBy = pr.closedBy().orElse(pr.author());\n        return new ArchiveItem(parent, \"cn\", pr.updatedAt(), pr.updatedAt(), closedBy, Map.of(\"PR-Closed-Notice\", \"0\"),\n                               () -> String.format(\"%sWithdrawn: %s\", subjectPrefix, pr.title()),\n                               () -> ArchiveMessages.composeReplyHeader(parent.createdAt(), hostUserToEmailAuthor.author(parent.author())),\n                               () -> ArchiveMessages.composeClosedNotice(pr),\n                               () -> ArchiveMessages.composeReplyFooter(pr));\n    }\n\n    static ArchiveItem integratedNotice(PullRequest pr, Repository localRepo, Commit commit, HostUserToEmailAuthor hostUserToEmailAuthor, ArchiveItem parent, String subjectPrefix) {\n        return new ArchiveItem(parent, \"in\", pr.updatedAt(), pr.updatedAt(), pr.author(), Map.of(\"PR-Integrated-Notice\", \"0\"),\n                               () -> String.format(\"%sIntegrated: %s\", subjectPrefix, pr.title()),\n                               () -> ArchiveMessages.composeReplyHeader(parent.createdAt(), hostUserToEmailAuthor.author(parent.author())),\n                               () -> ArchiveMessages.composeIntegratedNotice(pr, localRepo, commit),\n                               () -> ArchiveMessages.composeReplyFooter(pr));\n    }\n\n    private static final Pattern mentionPattern = Pattern.compile(\"@([\\\\w-]+)\");\n\n    private static Optional<ArchiveItem> findLastMention(String commentText, List<ArchiveItem> eligibleParents) {\n        var firstLine = commentText.lines().findFirst();\n        if (firstLine.isEmpty()) {\n            return Optional.empty();\n        }\n        var mentionMatcher = mentionPattern.matcher(firstLine.get());\n        if (mentionMatcher.find()) {\n            var username = mentionMatcher.group(1);\n            for (int i = eligibleParents.size() - 1; i >= 0; --i) {\n                if (eligibleParents.get(i).author.username().equals(username)) {\n                    return Optional.of(eligibleParents.get(i));\n                }\n            }\n        }\n        return Optional.empty();\n    }\n\n    static boolean containsQuote(String quote, String body) {\n        var compactQuote = quote.lines()\n                                .map(String::strip)\n                                .filter(line -> !line.isBlank())\n                                .takeWhile(line -> line.startsWith(\">\"))\n                                .map(line -> line.replaceAll(\"\\\\W\", \"\"))\n                                .collect(Collectors.joining());\n        if (!compactQuote.isBlank()) {\n            var compactBody = body.replaceAll(\"\\\\W\", \"\");\n            return compactBody.contains(compactQuote);\n        } else {\n            return false;\n        }\n    }\n\n    private static Optional<ArchiveItem> findLastQuoted(String commentText, List<ArchiveItem> eligibleParents) {\n        for (int i = eligibleParents.size() - 1; i >= 0; --i) {\n            if (containsQuote(commentText, eligibleParents.get(i).body())) {\n                return Optional.of(eligibleParents.get(i));\n            }\n        }\n        return Optional.empty();\n    }\n\n    static ArchiveItem findParent(List<ArchiveItem> generated, List<BridgedComment> bridgedComments, Comment comment) {\n        var eligible = new ArrayList<ArchiveItem>();\n        for (var item : generated) {\n            if (item.id().startsWith(\"pc\") || item.id().startsWith(\"rv\")) {\n                if (item.createdAt().isBefore(comment.createdAt())) {\n                    eligible.add(item);\n                }\n            }\n        }\n\n        var lastMention = findLastMention(comment.body(), eligible);\n        if (lastMention.isPresent()) {\n            return lastMention.get();\n        }\n\n        // It is possible to quote a bridged comment when replying - make these eligible as well\n        for (var bridged : bridgedComments) {\n            var item = new ArchiveItem(generated.get(0), \"br\" + bridged.messageId().address(), bridged.created(), bridged.created(),\n                                       bridged.author(), null, generated.get(0).subject, null, bridged::body, null);\n            eligible.add(item);\n        }\n\n        var lastQuoted = findLastQuoted(comment.body(), eligible);\n        if (lastQuoted.isPresent()) {\n            return lastQuoted.get();\n        }\n\n        ArchiveItem lastRevisionItem = generated.get(0);\n        for (var item : generated) {\n            if (item.id().startsWith(\"ha\")) {\n                if (item.createdAt().isBefore(comment.createdAt())) {\n                    lastRevisionItem = item;\n                }\n            }\n        }\n        return lastRevisionItem;\n    }\n\n    private static ArchiveItem findRevisionItem(List<ArchiveItem> generated, Hash hash) {\n        // Parent is revision update mail with the hash\n        ArchiveItem lastRevisionItem = generated.get(0);\n        // If no hash is given, that means the commit for the review/comment no longer exists.\n        // This means that no properly valid parent exists, but as we need to return one, just\n        // return the first element.\n        if (hash != null) {\n            for (var item : generated) {\n                if (item.id().startsWith(\"ha\")) {\n                    lastRevisionItem = item;\n                }\n                if (item.id().equals(\"ha\" + hash.hex())) {\n                    return item;\n                }\n            }\n        }\n        return lastRevisionItem;\n    }\n\n    static ArchiveItem findReviewCommentItem(List<ArchiveItem> generated, ReviewComment reviewComment) {\n        for (var item : generated) {\n            if (item.id().equals(\"rc\" + reviewComment.id())) {\n                return item;\n            }\n        }\n        throw new RuntimeException(\"Failed to find review comment\");\n    }\n\n    static ArchiveItem findParent(List<ArchiveItem> generated, Review review) {\n        return findRevisionItem(generated, review.hash().orElse(null));\n    }\n\n    static ArchiveItem findParent(List<ArchiveItem> generated, List<ReviewComment> reviewComments, ReviewComment reviewComment) {\n        // Parent is previous in thread or the revision update mail with the hash\n\n        var threadId = reviewComment.threadId();\n        var reviewThread = reviewComments.stream()\n                                         .filter(comment -> comment.threadId().equals(threadId))\n                                         .collect(Collectors.toList());\n        ReviewComment previousComment = null;\n        var eligible = new ArrayList<ArchiveItem>();\n        for (var threadComment : reviewThread) {\n            if (threadComment.equals(reviewComment)) {\n                break;\n            }\n            previousComment = threadComment;\n            eligible.add(findReviewCommentItem(generated, previousComment));\n        }\n\n        if (previousComment == null) {\n            return findRevisionItem(generated, reviewComment.hash().orElse(null));\n        } else {\n            var mentionedParent = findLastMention(reviewComment.body(), eligible);\n            if (mentionedParent.isPresent()) {\n                return mentionedParent.get();\n            } else {\n                return eligible.getLast();\n            }\n        }\n    }\n\n    String id() {\n        return id;\n    }\n\n    ZonedDateTime createdAt() {\n        return created;\n    }\n\n    ZonedDateTime updatedAt() {\n        return updated;\n    }\n\n    HostUser author() {\n        return author;\n    }\n\n    Map<String, String> extraHeaders() {\n        return extraHeaders;\n    }\n\n    Optional<ArchiveItem> parent() {\n        return Optional.ofNullable(parent);\n    }\n\n    String subject() {\n        return subject.get();\n    }\n\n    String header() {\n        if (resolvedHeader == null) {\n            resolvedHeader = header.get();\n        }\n        return resolvedHeader;\n    }\n\n    String body() {\n        if (resolvedBody == null) {\n            resolvedBody = body.get();\n        }\n        return resolvedBody;\n    }\n\n    String footer() {\n        if (resolvedFooter == null) {\n            resolvedFooter = footer.get();\n        }\n        return resolvedFooter;\n    }\n\n    @Override\n    public String toString() {\n        return \"ArchiveItem From: \" + author + \" Body: \" + body();\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/ArchiveMessages.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.Issue;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.time.ZonedDateTime;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\nimport static org.openjdk.skara.bots.common.PatternEnum.COMMENT_PATTERN;\n\nclass ArchiveMessages {\n\n    private static final String WEBREV_UNAVAILABLE_COMMENT = \"Webrev is not available because diff is too large\";\n    private static String filterCommentsAndCommands(String body) {\n        var parsedBody = PullRequestBody.parse(body);\n        body = parsedBody.bodyText();\n\n        var commentMatcher = COMMENT_PATTERN.getPattern().matcher(body);\n        body = commentMatcher.replaceAll(\"\");\n\n        body = ArchiveWorkItem.filterOutCommands(body);\n\n        body = MarkdownToText.removeFormatting(body);\n        return body.strip();\n    }\n\n    private static String formatCommitBrief(CommitMetadata commit) {\n        var ret = new StringBuilder();\n        var message = commit.message();\n        var abbrev = commit.hash().abbreviate();\n        if (message.size() == 0) {\n            ret.append(\" - \").append(abbrev).append(\": <no commit message found>\");\n        } else {\n            ret.append(\" - \").append(message.get(0));\n        }\n        return ret.toString();\n    }\n\n    private static String formatSingleCommit(CommitMetadata commit) {\n        var ret = new StringBuilder();\n        var message = commit.message();\n        if (message.size() == 0) {\n            var abbrev = commit.hash().abbreviate();\n            ret.append(\"  \").append(abbrev).append(\": <no commit message found>\");\n        } else {\n            ret.append(\"  \").append(String.join(\"\\n  \", message));\n        }\n        return ret.toString();\n    }\n\n    private static String formatCommitInList(CommitMetadata commit) {\n        var ret = new StringBuilder();\n        var message = commit.message();\n        if (message.size() == 0) {\n            var abbrev = commit.hash().abbreviate();\n            ret.append(\" - \").append(abbrev).append(\": <no commit message found>\");\n        } else {\n            ret.append(\" - \").append(String.join(\"\\n   \", message));\n        }\n        return ret.toString();\n    }\n\n    private static List<CommitMetadata> commits(Repository localRepo, Hash first, Hash last) {\n        try {\n            return localRepo.commitMetadata(first, last);\n        } catch (IOException e) {\n            return List.of();\n        }\n    }\n\n    private static URI commitsLink(PullRequest pr, Hash first, Hash last) {\n        return pr.repository().webUrl(first.abbreviate(), last.abbreviate());\n    }\n\n    private static String formatNumber(int number) {\n        switch (number) {\n            case 0: return \"no\";\n            case 1: return \"one\";\n            case 2: return \"two\";\n            case 3: return \"three\";\n            case 4: return \"four\";\n            case 5: return \"five\";\n            case 6: return \"six\";\n            case 7: return \"seven\";\n            case 8: return \"eight\";\n            case 9: return \"nine\";\n            default: return Integer.toString(number);\n        }\n    }\n\n    private static String describeCommits(List<CommitMetadata> commits, String adjective) {\n        return formatNumber(commits.size()) + (adjective.isBlank() ? \"\" : \" \" + adjective) +\n                \" commit\" + (commits.size() != 1 ? \"s\" : \"\");\n    }\n\n    private static Optional<String> formatCommitMessagesFull(List<CommitMetadata> commits, URI commitsLink) {\n        if (commits.size() == 0) {\n            return Optional.empty();\n        } else if (commits.size() == 1) {\n            return Optional.of(formatSingleCommit(commits.get(0)));\n        } else {\n            var commitSummary = commits.stream()\n                                      .limit(10)\n                                      .map(ArchiveMessages::formatCommitInList)\n                                      .collect(Collectors.joining(\"\\n\"));\n            if (commits.size() > 10) {\n                commitSummary += \"\\n - ... and \" + (commits.size() - 10) + \" more: \";\n                commitSummary += commitsLink.toString();\n            }\n            return Optional.of(commitSummary);\n        }\n    }\n\n    private static Optional<String> formatCommitMessagesBrief(List<CommitMetadata> commits, URI commitsLink) {\n        if (commits.size() == 0) {\n            return Optional.empty();\n        } else {\n            var commitSummary = commits.stream()\n                                       .limit(10)\n                                       .map(ArchiveMessages::formatCommitBrief)\n                                       .collect(Collectors.joining(\"\\n\"));\n            if (commits.size() > 10) {\n                commitSummary += \"\\n - ... and \" + (commits.size() - 10) + \" more: \";\n                commitSummary += commitsLink.toString();\n            }\n            return Optional.of(commitSummary);\n        }\n    }\n\n    private static Optional<String> issueUrl(PullRequest pr, URI issueTracker, String projectPrefix) {\n        var issue = Issue.fromStringRelaxed(pr.title());\n        return issue.map(value -> URIBuilder.base(issueTracker).appendPath(projectPrefix + \"-\" + value.shortId()).build().toString());\n    }\n\n    private static String stats(Repository localRepo, Hash base, Hash head) {\n        try {\n            var diff = localRepo.diff(base, head);\n            var diffStats = diff.totalStats();\n            var inserted = diffStats.added();\n            var deleted = diffStats.removed();\n            var modified = diffStats.modified();\n            var linesChanged = inserted + deleted + modified;\n            var filesChanged = diff.patches().size();\n            return String.format(\"%d line%s in %d file%s changed: %d ins; %d del; %d mod\",\n                                 linesChanged,\n                                 linesChanged == 1 ? \"\" : \"s\",\n                                 filesChanged,\n                                 filesChanged == 1 ? \"\" : \"s\",\n                                 inserted,\n                                 deleted,\n                                 modified);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    private static String fetchCommand(PullRequest pr) {\n        var repoUrl = pr.repository().url();\n        return \"git fetch \" + repoUrl + \" \" + pr.fetchRef() + \":pull/\" + pr.id();\n    }\n\n    static String composeConversation(PullRequest pr) {\n        var filteredBody = filterCommentsAndCommands(pr.body());\n        if (filteredBody.isEmpty()) {\n            filteredBody = pr.title();\n        }\n\n        return filteredBody;\n    }\n\n    static String composeIncrementalRevision(PullRequest pr, Repository localRepository, String author, Hash head, Hash lastHead, Hash base) {\n        var ret = new StringBuilder();\n\n        var incrementalUpdate = false;\n        try {\n            incrementalUpdate = localRepository.isAncestor(lastHead, head);\n        } catch (IOException ignored) {\n        }\n        var commits = commits(localRepository, lastHead, head);\n        var noIncrementalCommitsFound = commits.isEmpty();\n        if (noIncrementalCommitsFound) {\n            // Could not find incremental commits, get everything from the base instead\n            lastHead = base;\n            commits = commits(localRepository, lastHead, head);\n        }\n        var commitsLink = commitsLink(pr, lastHead, head);\n        var newCommitMessages = formatCommitMessagesFull(commits, commitsLink);\n\n        if (incrementalUpdate) {\n            ret.append(author);\n            ret.append(\" has updated the pull request incrementally\");\n            var commitsDescription = describeCommits(commits, \"additional\");\n            newCommitMessages.ifPresentOrElse(m -> ret.append(\" with \")\n                                                      .append(commitsDescription)\n                                                      .append(\" since the last revision:\\n\\n\")\n                                                      .append(m),\n                                              () -> ret.append(\".\"));\n        } else {\n            ret.append(author);\n            ret.append(\" has refreshed the contents of this pull request, and previous commits have been removed. \");\n            if (noIncrementalCommitsFound) {\n                ret.append(\"Incremental views are not available.\");\n                var commitsDescription = describeCommits(commits, \"\");\n                newCommitMessages.ifPresent(m -> ret.append(\" The pull request now contains \")\n                        .append(commitsDescription)\n                        .append(\":\\n\\n\")\n                        .append(m));\n            } else {\n                ret.append(\"The incremental views will show differences compared to the previous content of the PR.\");\n                var commitsDescription = describeCommits(commits, \"new\");\n                newCommitMessages.ifPresent(m -> ret.append(\" The pull request contains \")\n                        .append(commitsDescription)\n                        .append(\" since the last revision:\\n\\n\")\n                        .append(m));\n            }\n        }\n        return ret.toString();\n    }\n\n    static String composeRebasedIncrementalRevision(PullRequest pr, Repository localRepository, String author, Hash head, Hash lastHead) {\n        var ret = new StringBuilder();\n\n        ret.append(author);\n        ret.append(\" has updated the pull request with a new target base due to a merge or a rebase. \");\n        ret.append(\"The incremental webrev excludes the unrelated changes brought in by the merge/rebase.\");\n\n        var commits = commits(localRepository, lastHead, head);\n        var commitsLink = commitsLink(pr, lastHead, head);\n        var newCommitMessages = formatCommitMessagesFull(commits, commitsLink);\n        var commitsDescription = describeCommits(commits, \"additional\");\n        newCommitMessages.ifPresent(m -> ret.append(\" The pull request contains \")\n                                            .append(commitsDescription)\n                                            .append(\" since the last revision:\\n\\n\")\n                                            .append(m));\n        return ret.toString();\n    }\n\n    static String composeFullRevision(PullRequest pr, Repository localRepository, String author, Hash base, Hash head) {\n        var ret = new StringBuilder();\n\n        ret.append(author);\n        ret.append(\" has updated the pull request with a new target base due to a merge or a rebase.\");\n\n        var commits = commits(localRepository, base, head);\n        var commitsLink = commitsLink(pr, base, head);\n        var newCommitMessages = formatCommitMessagesFull(commits, commitsLink);\n        var commitsDescription = describeCommits(commits, \"\");\n        newCommitMessages.ifPresent(m -> ret.append(\" The pull request now contains \")\n                                            .append(commitsDescription)\n                                            .append(\":\\n\\n\")\n                                            .append(m));\n        return ret.toString();\n    }\n\n    static String composeReplySubject(String parentSubject) {\n        if (parentSubject.startsWith(\"Re: \")) {\n            return parentSubject;\n        } else {\n            return \"Re: \" + parentSubject;\n        }\n    }\n\n    private static Optional<String> composeDependsOn(PullRequest pr) {\n        var dependsId = PreIntegrations.dependentPullRequestId(pr);\n        if (dependsId.isEmpty()) {\n            return Optional.empty();\n        }\n\n        var dependsPr = pr.repository().pullRequest(dependsId.get());\n        return Optional.of(\"Depends on: \" + dependsPr.webUrl());\n    }\n\n    static String composeReplyFooter(PullRequest pr) {\n        return \"PR: \" + pr.webUrl();\n    }\n\n    static String composeCommentReplyFooter(PullRequest pr, Comment comment) {\n        return \"PR Comment: \" + pr.commentUrl(comment).toString();\n    }\n\n    static String composeReviewCommentReplyFooter(PullRequest pr, ReviewComment reviewComment) {\n        return \"PR Review Comment: \" + pr.reviewCommentUrl(reviewComment).toString();\n    }\n\n    static String composeReviewReplyFooter(PullRequest pr, Review review) {\n        return \"PR Review: \" + pr.reviewUrl(review).toString();\n    }\n\n    // When changing this, ensure that the PR pattern in the notifier still matches\n    static String composeConversationFooter(PullRequest pr, URI issueProject, String projectPrefix, Repository localRepo, WebrevDescription webrev, Hash base, Hash head) {\n        var commits = commits(localRepo, base, head);\n        var commitsLink = commitsLink(pr, base, head);\n        var issueString = issueUrl(pr, issueProject, projectPrefix).map(url -> \"  Issue: \" + url + \"\\n\").orElse(\"\");\n\n        return composeDependsOn(pr).map(line -> line + \"\\n\\n\").orElse(\"\") +\n                \"Commit messages:\\n\" +\n                formatCommitMessagesBrief(commits, commitsLink).orElse(\"\") + \"\\n\\n\" +\n                \"Changes: \" + pr.changeUrl() + \"\\n\" +\n                (webrev.diffTooLarge() ?\n                        \"  Webrev: \" + WEBREV_UNAVAILABLE_COMMENT + \"\\n\" :\n                        (webrev.uri() == null ? \"\" : \"  Webrev: \" + webrev.uri().toString() + \"\\n\")) +\n                issueString +\n                \"  Stats: \" + stats(localRepo, base, head) + \"\\n\" +\n                \"  Patch: \" + pr.diffUrl().toString() + \"\\n\" +\n                \"  Fetch: \" + fetchCommand(pr) + \"\\n\\n\" +\n                composeReplyFooter(pr);\n    }\n\n    static String composeMergeConversationFooter(PullRequest pr, Repository localRepo, List<WebrevDescription> webrevs, Hash base, Hash head) {\n        var commits = commits(localRepo, base, head);\n        var commitsLink = commitsLink(pr, base, head);\n        String webrevLinks;\n        if (webrevs.size() > 0) {\n            if (webrevs.stream().noneMatch(w -> w.uri() != null || w.diffTooLarge())) {\n                webrevLinks = \"\";\n            } else {\n                var containsConflicts = webrevs.stream().anyMatch(w -> w.type().equals(WebrevDescription.Type.MERGE_CONFLICT));\n                var containsMergeDiffs = webrevs.stream().anyMatch(w -> w.type().equals(WebrevDescription.Type.MERGE_TARGET) ||\n                        w.type().equals(WebrevDescription.Type.MERGE_SOURCE));\n\n                webrevLinks = \"The webrev\" + (webrevs.size() > 1 ? \"s\" : \"\") + \" contain\" + (webrevs.size() == 1 ? \"s\" : \"\") + \" \" +\n                        (containsConflicts ? \"the conflicts with \" + pr.targetRef() : \"\") +\n                        (containsConflicts && containsMergeDiffs ? \" and \" : \"\") +\n                        (containsMergeDiffs ? \"the adjustments done while merging with regards to each parent branch\" : \"\")\n                        + \":\\n\" +\n                        webrevs.stream()\n                                .map(d -> d.diffTooLarge() ?\n                                        String.format(\" - %s: %s\", d.shortLabel(), WEBREV_UNAVAILABLE_COMMENT) :\n                                        String.format(\" - %s: %s\", d.shortLabel(), d.uri()))\n                                .collect(Collectors.joining(\"\\n\")) + \"\\n\\n\";\n            }\n        } else {\n            webrevLinks = \"The merge commit only contains trivial merges, so no merge-specific webrevs have been generated.\\n\\n\";\n        }\n        return \"Commit messages:\\n\" +\n                formatCommitMessagesBrief(commits, commitsLink).orElse(\"\") + \"\\n\\n\" +\n                webrevLinks +\n                \"Changes: \" + pr.changeUrl() + \"\\n\" +\n                \"  Stats: \" + stats(localRepo, base, head) + \"\\n\" +\n                \"  Patch: \" + pr.diffUrl().toString() + \"\\n\" +\n                \"  Fetch: \" + fetchCommand(pr) + \"\\n\\n\" +\n                composeReplyFooter(pr);\n    }\n\n    static String composeRebasedFooter(PullRequest pr, Repository localRepo, WebrevDescription fullWebrev, Hash base, Hash head) {\n        return \"Changes: \" + pr.changeUrl() + \"\\n\" +\n                (fullWebrev.diffTooLarge() ?\n                        \"  Webrev: \" + WEBREV_UNAVAILABLE_COMMENT + \"\\n\" :\n                        (fullWebrev.uri() == null ? \"\" : \"  Webrev: \" + fullWebrev.uri().toString() + \"\\n\")) +\n                \"  Stats: \" + stats(localRepo, base, head) + \"\\n\" +\n                \"  Patch: \" + pr.diffUrl().toString() + \"\\n\" +\n                \"  Fetch: \" + fetchCommand(pr) + \"\\n\\n\" +\n                composeReplyFooter(pr);\n    }\n\n    static String composeIncrementalFooter(PullRequest pr, Repository localRepo, WebrevDescription fullWebrev, WebrevDescription incrementalWebrev, Hash head, Hash lastHead) {\n        return \"Changes:\\n\" +\n                \"  - all: \" + pr.changeUrl() + \"\\n\" +\n                \"  - new: \" + pr.changeUrl(lastHead) + \"\\n\\n\" +\n                (fullWebrev.diffTooLarge() ? \"Webrevs:\\n\" : fullWebrev.uri() == null ? \"\" : \"Webrevs:\\n\") +\n                (fullWebrev.diffTooLarge() ? \" - full: \" + WEBREV_UNAVAILABLE_COMMENT + \"\\n\" :\n                        fullWebrev.uri() == null ? \"\" : \" - full: \" + fullWebrev.uri().toString() + \"\\n\") +\n                (incrementalWebrev.diffTooLarge() ? \" - incr: \" + WEBREV_UNAVAILABLE_COMMENT + \"\\n\\n\" :\n                        incrementalWebrev.uri() == null ? \"\" : \" - incr: \" + incrementalWebrev.uri().toString() + \"\\n\\n\") +\n                \"  Stats: \" + stats(localRepo, lastHead, head) + \"\\n\" +\n                \"  Patch: \" + pr.diffUrl().toString() + \"\\n\" +\n                \"  Fetch: \" + fetchCommand(pr) + \"\\n\\n\" +\n                composeReplyFooter(pr);\n    }\n\n    static String composeComment(Comment comment) {\n        return filterCommentsAndCommands(comment.body());\n    }\n\n    static String composeReviewComment(PullRequest pr, ReviewComment reviewComment) {\n        var body = new StringBuilder();\n\n        // Add some context to the first post\n        if (reviewComment.parent().isEmpty()) {\n            body.append(reviewComment.path());\n            if (reviewComment.line() > 0) {\n                body.append(\" line \").append(reviewComment.line());\n            }\n            body.append(\":\\n\\n\");\n            if (reviewComment.hash().isPresent() && reviewComment.line() > 0) {\n                try {\n                    var contents = pr.repository().fileContents(reviewComment.path(), reviewComment.hash().get().hex())\n                            .orElseThrow(() -> new RuntimeException(\"Could not find \" + reviewComment.path() + \" on ref \"\n                                    + reviewComment.hash().get().hex() + \" in repo \" + pr.repository().name()))\n                            .lines().collect(Collectors.toList());\n                    for (int i = Math.max(0, reviewComment.line() - 3); i < Math.min(contents.size(), reviewComment.line()); ++i) {\n                        body.append(\"> \").append(i + 1).append(\": \").append(contents.get(i)).append(\"\\n\");\n                    }\n                    body.append(\"\\n\");\n                } catch (RuntimeException e) {\n                    body.append(\"> (failed to retrieve contents of file, check the PR for context)\\n\");\n                }\n            }\n        }\n        body.append(filterCommentsAndCommands(reviewComment.body()));\n        return body.toString();\n    }\n\n    private static String composeReviewVerdict(Review review, HostUserToUsername hostUserToUsername, HostUserToRole hostUserToRole) {\n        var result = new StringBuilder();\n        if (review.verdict() != Review.Verdict.NONE) {\n            if (review.verdict() == Review.Verdict.APPROVED) {\n                result.append(\"Marked as reviewed\");\n            } else {\n                result.append(\"Changes requested\");\n            }\n            result.append(\" by \");\n            result.append(hostUserToUsername.username(review.reviewer()));\n            result.append(\" (\");\n            result.append(hostUserToRole.role(review.reviewer()));\n            result.append(\").\");\n        }\n        return result.toString();\n    }\n\n    static String composeReview(PullRequest pr, Review review, HostUserToUsername hostUserToUsername, HostUserToRole hostUserToRole) {\n        if (review.body().isPresent() && !review.body().get().isBlank()) {\n            return filterCommentsAndCommands(review.body().get());\n        } else {\n            return composeReviewVerdict(review, hostUserToUsername, hostUserToRole);\n        }\n    }\n\n    static String composeReviewFooter(PullRequest pr, Review review, HostUserToUsername hostUserToUsername, HostUserToRole hostUserToRole) {\n        var result = new StringBuilder();\n        if (review.body().isPresent() && !review.body().get().isBlank()) {\n            var verdict = composeReviewVerdict(review, hostUserToUsername, hostUserToRole);\n            if (!verdict.isBlank()) {\n                result.append(verdict);\n                result.append(\"\\n\\n\");\n            }\n        }\n        result.append(composeReviewReplyFooter(pr, review));\n        return result.toString();\n    }\n\n    static String composeReplyHeader(ZonedDateTime parentDate, EmailAddress parentAuthor) {\n        return \"On \" + parentDate.format(DateTimeFormatter.RFC_1123_DATE_TIME) + \", \" + parentAuthor.toString() + \" wrote:\";\n    }\n\n    static String composeClosedNotice(PullRequest pr) {\n        return \"This pull request has been closed without being integrated.\";\n    }\n\n    static String composeIntegratedNotice(PullRequest pr, Repository localRepo, Commit commit) {\n        var result = new StringBuilder();\n        result.append(\"This pull request has now been integrated.\\n\\n\");\n        result.append(\"Changeset: \").append(commit.hash().abbreviate()).append(\"\\n\");\n        result.append(\"Author:    \").append(commit.author().name()).append(\" <\").append(commit.author().email()).append(\">\\n\");\n        if (!commit.author().equals(commit.committer())) {\n            result.append(\"Committer: \").append(commit.committer().name()).append(\" <\").append(commit.committer().email()).append(\">\\n\");\n        }\n        result.append(\"URL:       \").append(pr.repository().webUrl(commit.hash())).append(\"\\n\");\n        result.append(\"Stats:     \").append(stats(localRepo, commit.parents().get(0), commit.hash())).append(\"\\n\");\n        result.append(\"\\n\");\n        result.append(String.join(\"\\n\", commit.message()));\n\n        return result.toString();\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/ArchiveReaderWorkItem.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.mailinglist.MailingListReader;\n\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.*;\n\npublic class ArchiveReaderWorkItem implements WorkItem {\n    private final MailingListArchiveReaderBot bot;\n    private final MailingListReader list;\n\n    ArchiveReaderWorkItem(MailingListArchiveReaderBot bot, MailingListReader list) {\n        this.bot = bot;\n        this.list = list;\n    }\n\n    @Override\n    public String toString() {\n        return \"ArchiveReaderWorkItem@\" + bot.repository().name();\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof ArchiveReaderWorkItem otherItem)) {\n            return true;\n        }\n        if (!list.equals(otherItem.list)) {\n            return true;\n        }\n        return false;\n    }\n\n    /**\n     * An ArchiveReaderWorkItem can't run concurrently with another item that shares the same\n     * MailingListReader, but it only replaces an item that acts on the same repository.\n     */\n    @Override\n    public boolean replaces(WorkItem other) {\n        return !concurrentWith(other)\n                && (other instanceof ArchiveReaderWorkItem archiveReaderWorkItem)\n                && bot.repository().name().equals(archiveReaderWorkItem.bot.repository().name());\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        // Give the bot a chance to act on all found messages\n        var conversations = list.conversations(Duration.ofDays(365));\n        for (var conversation : conversations) {\n            bot.inspect(conversation);\n        }\n        return List.of();\n    }\n\n    @Override\n    public String botName() {\n        return MailingListBridgeBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return \"archive-reader\";\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/ArchiveWorkItem.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport java.util.logging.Level;\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.bots.common.BotUtils;\nimport org.openjdk.skara.bots.common.CommandNameEnum;\nimport org.openjdk.skara.bots.common.PullRequestConstants;\nimport org.openjdk.skara.email.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.mailinglist.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.time.*;\nimport java.util.*;\nimport java.util.function.*;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\nimport static org.openjdk.skara.bots.common.PatternEnum.EXECUTION_COMMAND_PATTERN;\nimport static org.openjdk.skara.bots.common.PullRequestConstants.WEBREV_COMMENT_MARKER;\n\nclass ArchiveWorkItem implements WorkItem {\n    private final PullRequest pr;\n    private final MailingListBridgeBot bot;\n    private final Consumer<RuntimeException> exceptionConsumer;\n    private final Consumer<Instant> retryConsumer;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.mlbridge\");\n\n    ArchiveWorkItem(PullRequest pr, MailingListBridgeBot bot, Consumer<RuntimeException> exceptionConsumer, Consumer<Instant> retryConsumer) {\n        this.pr = pr;\n        this.bot = bot;\n        this.exceptionConsumer = exceptionConsumer;\n        this.retryConsumer = retryConsumer;\n    }\n\n    @Override\n    public String toString() {\n        return \"ArchiveWorkItem@\" + bot.codeRepo().name() + \"#\" + pr.id();\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof ArchiveWorkItem otherArchiveItem)) {\n            if (!(other instanceof LabelsUpdaterWorkItem otherLabelsUpdaterItem)) {\n                return true;\n            }\n            if (!bot.equals(otherLabelsUpdaterItem.bot())) {\n                return true;\n            }\n            return false;\n        }\n        if (!pr.isSame(otherArchiveItem.pr)) {\n            return true;\n        }\n        return false;\n    }\n\n    private void pushMbox(Repository localRepo, String message) {\n        IOException lastException = null;\n        Hash hash;\n        try {\n            localRepo.add(localRepo.root().resolve(\".\"));\n            hash = localRepo.commit(message, bot.emailAddress().fullName().orElseThrow(), bot.emailAddress().address());\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n\n        for (int counter = 0; counter < 3; ++counter) {\n            try {\n                localRepo.push(hash, bot.archiveRepo().authenticatedUrl(), bot.archiveRef());\n                return;\n            } catch (IOException e) {\n                log.info(\"Push to archive failed: \" + e);\n                try {\n                    var remoteHead = localRepo.fetch(bot.archiveRepo().authenticatedUrl(), bot.archiveRef(), false).orElseThrow();\n                    localRepo.rebase(remoteHead, bot.emailAddress().fullName().orElseThrow(), bot.emailAddress().address());\n                    hash = localRepo.head();\n                    log.info(\"Rebase successful -  new hash: \" + hash);\n                } catch (IOException e2) {\n                    throw new UncheckedIOException(e2);\n                }\n\n                lastException = e;\n            }\n        }\n        throw new UncheckedIOException(lastException);\n    }\n\n    private Repository materializeArchive(Path scratchPath) {\n        try {\n            return Repository.materialize(scratchPath, bot.archiveRepo().authenticatedUrl(),\n                                          \"+\" + bot.archiveRef() + \":mlbridge_archive\");\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    static String filterOutCommands(String body) {\n        var filteredBody = new StringBuilder();\n        boolean readingMultiLineCommandArgs = false;\n        for (var line : body.split(\"\\\\R\")) {\n            var preprocessedLine = BotUtils.preprocessCommandLine(line);\n            var commandMatcher = EXECUTION_COMMAND_PATTERN.getPattern().matcher(preprocessedLine);\n            if (commandMatcher.matches()) {\n                readingMultiLineCommandArgs = false;\n                var command = commandMatcher.group(1).toLowerCase();\n                if (Arrays.stream(CommandNameEnum.values()).anyMatch(commandNameEnum -> commandNameEnum.name().equals(command))\n                        && CommandNameEnum.valueOf(command).isMultiLine()) {\n                    readingMultiLineCommandArgs = true;\n                }\n            } else {\n                if (!readingMultiLineCommandArgs) {\n                    filteredBody.append(line).append(System.lineSeparator());\n                }\n            }\n        }\n        return filteredBody.toString().strip();\n    }\n\n    private boolean ignoreComment(HostUser author, String body, ZonedDateTime createdTime, ZonedDateTime lastDraftTime, boolean isComment) {\n        if (pr.repository().forge().currentUser().equals(author)) {\n            if (pr.isOpen()) {\n                return !PullRequestConstants.READY_FOR_SPONSOR_MARKER_PATTERN.matcher(body).find();\n            } else {\n                return true;\n            }\n        }\n        if (bot.ignoredUsers().contains(author.username())) {\n            if (pr.isOpen()) {\n                return !PullRequestConstants.READY_FOR_SPONSOR_MARKER_PATTERN.matcher(body).find();\n            } else {\n                return true;\n            }\n        }\n\n        // Check if this comment only contains command lines\n        // For reviews, while the body is empty or only contains command, the bot should still archive it\n        if (isComment && filterOutCommands(body).isEmpty()) {\n            return true;\n        }\n\n        for (var ignoredCommentPattern : bot.ignoredComments()) {\n            var ignoredCommentMatcher = ignoredCommentPattern.matcher(body);\n            if (ignoredCommentMatcher.find()) {\n                return true;\n            }\n        }\n        // If the pull request was converted to draft, the comments\n        // after the last converted time should be ignored.\n        if (pr.isDraft()) {\n            if (lastDraftTime != null && lastDraftTime.isBefore(createdTime)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private static final String webrevHeaderMarker = \"<!-- mlbridge webrev header -->\";\n    private static final String webrevListMarker = \"<!-- mlbridge webrev list -->\";\n\n    private void updateWebrevComment(List<Comment> comments, int index, List<WebrevDescription> webrevs) {\n        if (webrevs.stream().noneMatch(w -> (w.uri() != null || w.diffTooLarge()))) {\n            return;\n        }\n        var existing = comments.stream()\n                               .filter(comment -> comment.author().equals(pr.repository().forge().currentUser()))\n                               .filter(comment -> comment.body().contains(WEBREV_COMMENT_MARKER))\n                               .findAny();\n        var webrevDescriptions = webrevs.stream()\n                .map(d -> d.diffTooLarge() ?\n                        String.format(\"[%s](%s)\", d.label(), \"Webrev is not available because diff is too large\") :\n                        String.format(\"[%s](%s)\", d.label(), d.uri()))\n                .collect(Collectors.joining(\" - \"));\n        var comment = WEBREV_COMMENT_MARKER + \"\\n\";\n        comment += webrevHeaderMarker + \"\\n\";\n        comment += \"### Webrevs\" + \"\\n\";\n        comment += webrevListMarker + \"\\n\";\n        comment += \" * \" + String.format(\"%02d\", index) + \": \" + webrevDescriptions;\n        comment += \" ([\" + pr.headHash().abbreviate() + \"](\" + pr.filesUrl(pr.headHash()) + \"))\\n\";\n\n        if (existing.isPresent()) {\n            if (existing.get().body().contains(webrevDescriptions)) {\n                log.fine(\"Webrev links already posted - skipping update\");\n                return;\n            }\n            var previousListStart = existing.get().body().indexOf(webrevListMarker) + webrevListMarker.length() + 1;\n            var previousList = existing.get().body().substring(previousListStart);\n            comment += previousList;\n            pr.updateComment(existing.get().id(), comment);\n        } else {\n            pr.addComment(comment);\n        }\n    }\n\n    private EmailAddress getAuthorAddress(CensusInstance censusInstance, HostUser originalAuthor) {\n        if (bot.ignoredUsers().contains(originalAuthor.username())) {\n            return bot.emailAddress();\n        }\n        if (BridgedComment.isBridgedUser(originalAuthor)) {\n            return EmailAddress.from(originalAuthor.fullName(), originalAuthor.email().orElseThrow());\n        }\n\n        var contributor = censusInstance.namespace().get(originalAuthor.id());\n        if (contributor == null) {\n            return EmailAddress.from(originalAuthor.fullName(), bot.emailAddress().address());\n        } else {\n            return EmailAddress.from(contributor.fullName().orElse(originalAuthor.fullName()),\n                                     contributor.username() + \"@\" + censusInstance.configuration().census().domain());\n        }\n    }\n\n    private String getAuthorUsername(CensusInstance censusInstance, HostUser originalAuthor) {\n        var contributor = censusInstance.namespace().get(originalAuthor.id());\n        var username = contributor != null ? contributor.username() : originalAuthor.username() + \"@\" + censusInstance.namespace().name();\n        return username;\n    }\n\n    private String getAuthorRole(CensusInstance censusInstance, HostUser originalAuthor) {\n        var version = censusInstance.configuration().census().version();\n        var contributor = censusInstance.namespace().get(originalAuthor.id());\n        if (contributor == null) {\n            return \"no known OpenJDK username\";\n        } else if (censusInstance.project().isLead(contributor.username(), version)) {\n            return \"Lead\";\n        } else if (censusInstance.project().isReviewer(contributor.username(), version)) {\n            return \"Reviewer\";\n        } else if (censusInstance.project().isCommitter(contributor.username(), version)) {\n            return \"Committer\";\n        } else if (censusInstance.project().isAuthor(contributor.username(), version)) {\n            return \"Author\";\n        }\n        return \"no project role\";\n    }\n\n    private String subjectPrefix() {\n        var ret = new StringBuilder();\n        var branchName = pr.targetRef();\n        var repoName = Path.of(pr.repository().name()).getFileName().toString();\n        var useBranchInSubject = bot.branchInSubject().matcher(branchName).matches();\n        var useRepoInSubject = bot.repoInSubject();\n\n        if (useBranchInSubject || useRepoInSubject) {\n            ret.append(\"[\");\n            if (useRepoInSubject) {\n                ret.append(repoName);\n                if (useBranchInSubject) {\n                    ret.append(\":\");\n                }\n            }\n            if (useBranchInSubject) {\n                ret.append(branchName);\n            }\n            ret.append(\"] \");\n        }\n        return ret.toString();\n    }\n\n    private String mboxFile() {\n        return bot.codeRepo().name() + \"/\" + pr.id() + \".mbox\";\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        var path = scratchPath.resolve(\"mlbridge\");\n\n        var sentMails = new ArrayList<Email>();\n        // Load in already sent emails from the archive, if there are any.\n        var archiveContents = bot.archiveRepo().fileContents(mboxFile(), bot.archiveRef());\n        archiveContents.ifPresent(s -> sentMails.addAll(Mbox.splitMbox(s, bot.emailAddress())));\n\n        var labels = new HashSet<>(pr.labelNames());\n\n        // First determine if this PR should be inspected further or not\n        if (sentMails.isEmpty()) {\n            if (pr.state() == Issue.State.OPEN) {\n                for (var readyLabel : bot.readyLabels()) {\n                    if (!labels.contains(readyLabel)) {\n                        log.fine(\"PR is not yet ready - missing label '\" + readyLabel + \"'\");\n                        return List.of();\n                    }\n                }\n            } else {\n                if (!labels.contains(\"integrated\")) {\n                    log.fine(\"Closed PR was not integrated - will not initiate an RFR thread\");\n                    return List.of();\n                }\n            }\n        }\n\n        // If the PR is closed and the target ref no longer exists, we cannot process it\n        if (pr.isClosed()) {\n            if (pr.repository().branches().stream().noneMatch(n -> n.name().equals(pr.targetRef()))) {\n                log.warning(\"Target branch of PR '\" + pr.targetRef() + \"' no longer exists, cannot process further\");\n                return List.of();\n            }\n        }\n\n        // Also inspect comments before making the first post\n        var comments = pr.comments();\n        if (sentMails.isEmpty()) {\n            for (var readyComment : bot.readyComments().entrySet()) {\n                var commentFound = false;\n                for (var comment : comments) {\n                    if (comment.author().username().equals(readyComment.getKey())) {\n                        var matcher = readyComment.getValue().matcher(comment.body());\n                        if (matcher.find()) {\n                            commentFound = true;\n                            break;\n                        }\n                    }\n                }\n                if (!commentFound) {\n                    log.fine(\"PR is not yet ready - missing ready comment from '\" + readyComment.getKey() +\n                                     \"containing '\" + readyComment.getValue().pattern() + \"'\");\n                    return List.of();\n                }\n            }\n        }\n\n        // Determine recipient list(s)\n        var recipients = new ArrayList<EmailAddress>();\n        for (var candidateList : bot.lists()) {\n            if (candidateList.labels().isEmpty()) {\n                recipients.add(candidateList.list());\n                continue;\n            }\n            for (var label : labels) {\n                if (candidateList.labels().contains(label)) {\n                    recipients.add(candidateList.list());\n                    break;\n                }\n            }\n        }\n        if (recipients.isEmpty()) {\n            log.fine(\"PR does not match any recipient list: \" + pr.repository().name() + \"#\" + pr.id());\n            return List.of();\n        }\n\n        var census = CensusInstance.create(bot.censusRepo(), bot.censusRef(), scratchPath.resolve(\"census\"), pr);\n        var jbs = census.configuration().general().jbs();\n        if (jbs == null) {\n            jbs = census.configuration().general().project();\n        }\n\n        // Materialize the PR's target ref\n        try {\n            // Materialize the PR's source and target ref\n            var seedPath = bot.seedStorage().orElse(scratchPath.resolve(\"seeds\"));\n            var hostedRepositoryPool = new HostedRepositoryPool(seedPath);\n            var localRepoPath = scratchPath.resolve(\"mlbridge-mergebase\").resolve(pr.repository().name());\n            var localRepo = PullRequestUtils.materialize(hostedRepositoryPool, pr, localRepoPath);\n\n            var jsonWebrevPath = scratchPath.resolve(\"mlbridge-webrevs\").resolve(\"json\");\n            var htmlWebrevPath = scratchPath.resolve(\"mlbridge-webrevs\").resolve(\"html\");\n            var archiver = new ReviewArchive(pr, bot.emailAddress());\n            var lastDraftTime = pr.lastMarkedAsDraftTime().orElse(null);\n\n            // Regular comments\n            for (var comment : comments) {\n                if (ignoreComment(comment.author(), comment.body(), comment.createdAt(), lastDraftTime, true)) {\n                    archiver.addIgnored(comment);\n                } else {\n                    archiver.addComment(comment);\n                }\n            }\n\n            // Review comments\n            var reviews = pr.reviews();\n            for (var review : reviews) {\n                if (ignoreComment(review.reviewer(), review.body().orElse(\"\"), review.createdAt(), lastDraftTime, false)) {\n                    continue;\n                }\n                archiver.addReview(review);\n            }\n\n            // File specific comments\n            var reviewComments = pr.reviewComments().stream()\n                                   .sorted(Comparator.comparing(ReviewComment::line))\n                                   .sorted(Comparator.comparing(ReviewComment::path))\n                                   .collect(Collectors.toList());\n            for (var reviewComment : reviewComments) {\n                if (ignoreComment(reviewComment.author(), reviewComment.body(), reviewComment.createdAt(), lastDraftTime, true)) {\n                    continue;\n                }\n                archiver.addReviewComment(reviewComment);\n            }\n\n            var webrevGenerator = bot.webrevStorage().generator(pr, localRepo, jsonWebrevPath, htmlWebrevPath, hostedRepositoryPool);\n            var newMails = archiver.generateNewEmails(sentMails, bot.cooldown(), localRepo, bot.issueTracker(), jbs.toUpperCase(), webrevGenerator,\n                                                      (index, webrevs) -> updateWebrevComment(comments, index, webrevs),\n                                                      user -> getAuthorAddress(census, user),\n                                                      user -> getAuthorUsername(census, user),\n                                                      user -> getAuthorRole(census, user),\n                                                      subjectPrefix(),\n                                                      retryConsumer\n                                                      );\n            if (newMails.isEmpty()) {\n                return List.of();\n            }\n\n            // Push all new mails to the archive repository\n            var newArchivedContents = new StringBuilder();\n            archiveContents.ifPresent(newArchivedContents::append);\n            for (var newMail : newMails) {\n                var forArchiving = Email.from(newMail)\n                                        .recipient(EmailAddress.from(pr.id() + \"@mbox\"))\n                                        .build();\n                newArchivedContents.append(Mbox.fromMail(forArchiving));\n            }\n            bot.archiveRepo().writeFileContents(mboxFile(), newArchivedContents.toString(), new Branch(bot.archiveRef()),\n                    \"Adding comments for PR \" + bot.codeRepo().name() + \"/\" + pr.id(),\n                    bot.emailAddress().fullName().orElseThrow(), bot.emailAddress().address(), archiveContents.isEmpty());\n\n            // Finally post all new mails to the actual list\n            for (var newMail : newMails) {\n                var filteredHeaders = newMail.headers().stream()\n                                             .filter(header -> !header.startsWith(\"PR-\"))\n                                             .collect(Collectors.toMap(Function.identity(),\n                                                                       newMail::headerValue));\n                var filteredEmail = Email.from(newMail)\n                                         .replaceHeaders(filteredHeaders)\n                                         .headers(bot.headers())\n                                         .recipients(recipients)\n                                         .build();\n                bot.mailingListServer().post(filteredEmail);\n            }\n            // Mixing forge time and local time for the latency is not ideal, but the best\n            // we can do here.\n            var latency = Duration.between(pr.updatedAt(), ZonedDateTime.now());\n            log.log(Level.INFO, \"Time from PR updated to emails sent \" + latency, latency);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        return List.of();\n    }\n\n    @Override\n    public void handleRuntimeException(RuntimeException e) {\n        exceptionConsumer.accept(e);\n    }\n\n    @Override\n    public String botName() {\n        return MailingListBridgeBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return \"archive\";\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/BridgedComment.java",
    "content": "/*\n * Copyright (c) 2021, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.email.*;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.nio.charset.StandardCharsets;\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\npublic class BridgedComment {\n    private final EmailAddress messageId;\n    private final String body;\n    private final HostUser author;\n    private final ZonedDateTime created;\n\n    private static final String BRIDGED_MAIL_MARKER = \"<!-- Bridged id (%s) -->\";\n    static final Pattern BRIDGED_MAIL_ID = Pattern.compile(\"^<!-- Bridged id \\\\(([=+/\\\\w]+)\\\\) -->\");\n    private static final Pattern BRIDGED_SENDER = Pattern.compile(\"Mailing list message from \\\\[(.*?)]\\\\(mailto:(\\\\S+)\\\\)\");\n\n    private BridgedComment(String body, EmailAddress messageId, HostUser author, ZonedDateTime created) {\n        this.messageId = messageId;\n        this.body = body;\n        this.author = author;\n        this.created = created;\n    }\n\n    static Optional<BridgedComment> from(Comment comment, HostUser botUser) {\n        if (!comment.author().equals(botUser)) {\n            return Optional.empty();\n        }\n        var matcher = BRIDGED_MAIL_ID.matcher(comment.body());\n        if (!matcher.find()) {\n            return Optional.empty();\n        }\n        var id = new String(Base64.getDecoder().decode(matcher.group(1)), StandardCharsets.UTF_8);\n        var senderMatcher = BRIDGED_SENDER.matcher(comment.body());\n        if (!senderMatcher.find()) {\n            return Optional.empty();\n        }\n        var author = HostUser.builder()\n                             .id(\"bridged\")\n                             .username(\"bridged\")\n                             .fullName(senderMatcher.group(1))\n                             .email(senderMatcher.group(2))\n                             .build();\n        var headerEnd = comment.body().indexOf(\"\\n\\n\", senderMatcher.end());\n        var bridgedBody = comment.body().substring(headerEnd).strip();\n        return Optional.of(new BridgedComment(bridgedBody, EmailAddress.from(id), author, comment.createdAt()));\n    }\n\n    static BridgedComment post(PullRequest pr, Email email) {\n        var marker = String.format(BRIDGED_MAIL_MARKER,\n                                   Base64.getEncoder().encodeToString(email.id().address().getBytes(StandardCharsets.UTF_8)));\n\n        var filteredEmail = QuoteFilter.stripLinkBlock(email.body(), pr.webUrl());\n        var body = marker + \"\\n\" +\n                \"*Mailing list message from [\" + email.author().fullName().orElse(email.author().localPart()) +\n                \"](mailto:\" + email.author().address() + \") on [\" + email.sender().localPart() +\n                \"](mailto:\" + email.sender().address() + \"):*\\n\\n\" +\n                TextToMarkdown.escapeFormatting(filteredEmail);\n        if (body.length() > 64000) {\n            body = body.substring(0, 64000) + \"...\\n\\n\" + \"\" +\n                    \"This message was too large to bridge in full, and has been truncated. \" +\n                    \"Please check the mailing list archive to see the full text.\";\n        }\n        var comment = pr.addComment(body);\n        return BridgedComment.from(comment, pr.repository().forge().currentUser()).orElseThrow();\n    }\n\n    public EmailAddress messageId() {\n        return messageId;\n    }\n\n    public String body() {\n        return body;\n    }\n\n    public HostUser author() {\n        return author;\n    }\n\n    public ZonedDateTime created() {\n        return created;\n    }\n\n    public static boolean isBridgedUser(HostUser user) {\n        // All supported platforms use numerical IDs, so this special one can not cause conflicts\n        return user.id().equals(\"bridged\");\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/CensusInstance.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.census.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.jcheck.JCheckConfiguration;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.net.URLEncoder;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Path;\nimport java.util.Optional;\nimport java.util.stream.Collectors;\n\nclass CensusInstance {\n    private final Census census;\n    private final JCheckConfiguration configuration;\n    private final Project project;\n    private final Namespace namespace;\n\n    private CensusInstance(Census census, JCheckConfiguration configuration, Project project, Namespace namespace) {\n        this.census = census;\n        this.configuration = configuration;\n        this.project = project;\n        this.namespace = namespace;\n    }\n\n    private static Repository initialize(HostedRepository repo, String ref, Path folder) {\n        try {\n            return Repository.materialize(folder, repo.authenticatedUrl(), \"+\" + ref + \":\" + \"mlbridge_census_\" + repo.name());\n        } catch (IOException e) {\n            throw new RuntimeException(\"Failed to retrieve census to \" + folder, e);\n        }\n    }\n\n    private static Project project(JCheckConfiguration configuration, Census census) {\n        var project = census.project(configuration.general().project());\n\n        if (project == null) {\n            throw new RuntimeException(\"Project not found in census: \" + configuration.general().project());\n        }\n\n        return project;\n    }\n\n    private static Namespace namespace(Census census, String hostNamespace) {\n        //var namespace = census.namespace(pr.repository().getNamespace());\n        var namespace = census.namespace(hostNamespace);\n        if (namespace == null) {\n            throw new RuntimeException(\"Namespace not found in census: \" + hostNamespace);\n        }\n\n        return namespace;\n    }\n\n    private static JCheckConfiguration configuration(HostedRepository remoteRepo, String ref) {\n        var confFile = remoteRepo.fileContents(\".jcheck/conf\", ref)\n                .orElseThrow(() -> new RuntimeException(\"Could not find .jcheck/conf on ref \" + ref + \" in repo \" + remoteRepo.name()));\n        return JCheckConfiguration.parse(confFile.lines().collect(Collectors.toList()));\n    }\n\n    static CensusInstance create(HostedRepository censusRepo, String censusRef, Path folder, PullRequest pr) {\n        var repoName = censusRepo.url().getHost() + \"/\" + censusRepo.name();\n        var repoFolder = folder.resolve(URLEncoder.encode(repoName, StandardCharsets.UTF_8));\n        try {\n            var localRepo = Repository.get(repoFolder)\n                                      .or(() -> Optional.of(initialize(censusRepo, censusRef, repoFolder)))\n                                      .orElseThrow();\n            var hash = localRepo.fetch(censusRepo.authenticatedUrl(), censusRef, false).orElseThrow();\n            localRepo.checkout(hash, true);\n        } catch (IOException e) {\n            initialize(censusRepo, censusRef, repoFolder);\n        }\n\n        try {\n            var configuration = configuration(pr.repository(), pr.targetRef());\n            var census = Census.parse(repoFolder);\n            var project = project(configuration, census);\n            var namespace = namespace(census, pr.repository().namespace());\n            return new CensusInstance(census, configuration, project, namespace);\n        } catch (IOException e) {\n            throw new UncheckedIOException(\"Cannot parse census at \" + repoFolder, e);\n        }\n    }\n\n    JCheckConfiguration configuration() {\n        return configuration;\n    }\n\n    Project project() {\n        return project;\n    }\n\n    Namespace namespace() {\n        return namespace;\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/CommentPosterWorkItem.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport java.util.logging.Level;\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.email.*;\nimport org.openjdk.skara.forge.PullRequest;\n\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.function.Consumer;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\npublic class CommentPosterWorkItem implements WorkItem {\n    private final PullRequest pr;\n    private final List<Email> newMessages;\n    private final Consumer<RuntimeException> errorHandler;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.mlbridge\");\n\n    CommentPosterWorkItem(PullRequest pr, List<Email> newMessages, Consumer<RuntimeException> errorHandler) {\n        this.pr = pr;\n        this.newMessages = newMessages;\n        this.errorHandler = errorHandler;\n    }\n\n    @Override\n    public String toString() {\n        return \"CommentPosterWorkItem@\" + pr.repository().name() + \"#\" + pr.id();\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof CommentPosterWorkItem otherItem)) {\n            return true;\n        }\n        if (!pr.isSame(otherItem.pr)) {\n            return true;\n        }\n        var otherItemIds = otherItem.newMessages.stream()\n                                                .map(Email::id)\n                                                .collect(Collectors.toSet());\n        var overlap = newMessages.stream()\n                                 .map(Email::id)\n                                 .filter(otherItemIds::contains)\n                                 .findAny();\n        return overlap.isEmpty();\n    }\n\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        var comments = pr.comments();\n\n        var alreadyBridged = new HashSet<EmailAddress>();\n        for (var comment : comments) {\n            var bridged = BridgedComment.from(comment, pr.repository().forge().currentUser());\n            bridged.ifPresent(bridgedComment -> alreadyBridged.add(bridgedComment.messageId()));\n        }\n\n        for (var message : newMessages) {\n            if (alreadyBridged.contains(message.id())) {\n                log.fine(\"Message \" + message.id() + \" from \" + message.author() + \" to \" + pr + \" has already been bridged - skipping!\");\n                continue;\n            }\n\n            log.info(\"Bridging new message \" + message.id() + \" from \" + message.author() + \" to \" + pr);\n            BridgedComment.post(pr, message);\n            // Timestamp from email and a local date is the best we can do for latency here\n            var latency = Duration.between(message.date(), ZonedDateTime.now());\n            log.log(Level.INFO, \"Time from message date to posting comment \" + latency, latency);\n        }\n        return List.of();\n    }\n\n    @Override\n    public void handleRuntimeException(RuntimeException e) {\n        errorHandler.accept(e);\n    }\n\n    @Override\n    public String botName() {\n        return MailingListBridgeBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return \"comment-poster\";\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/CooldownQuarantine.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.forge.*;\n\nimport java.time.*;\nimport java.util.*;\nimport java.util.logging.Logger;\n\npublic class CooldownQuarantine {\n    private final Map<String, Instant> quarantineEnd = new HashMap<>();\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.mlbridge\");\n\n    enum Status {\n        NOT_IN_QUARANTINE,\n        IN_QUARANTINE,\n        JUST_RELEASED\n    }\n\n    public synchronized Status status(PullRequest pr) {\n        var uniqueId = pr.webUrl().toString();\n\n        if (!quarantineEnd.containsKey(uniqueId)) {\n            return Status.NOT_IN_QUARANTINE;\n        }\n        var end = quarantineEnd.get(uniqueId);\n        if (end.isBefore(Instant.now())) {\n            log.info(\"Released from cooldown quarantine: \" + pr.repository().name() + \"#\" + pr.id());\n            quarantineEnd.remove(uniqueId);\n            return Status.JUST_RELEASED;\n        }\n        log.info(\"Quarantined due to cooldown: \" + pr.repository().name() + \"#\" + pr.id());\n        return Status.IN_QUARANTINE;\n    }\n\n    public synchronized void updateQuarantineEnd(PullRequest pr, Instant end) {\n        var uniqueId = pr.webUrl().toString();\n        var currentEnd = quarantineEnd.getOrDefault(uniqueId, Instant.now());\n        if (end.isAfter(currentEnd)) {\n            quarantineEnd.put(uniqueId, end);\n        }\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/EmojiTable.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport java.util.*;\n\nclass EmojiTable {\n    static final Map<String, String> mapping = Map.<String, String>ofEntries(\n            Map.entry(\"+1\", \"👍\"),\n            Map.entry(\"100\", \"💯\"),\n            Map.entry(\"1234\", \"🔢\"),\n            Map.entry(\"8ball\", \"🎱\"),\n            Map.entry(\"a\", \"🅰\"),\n            Map.entry(\"ab\", \"🆎\"),\n            Map.entry(\"abc\", \"🔤\"),\n            Map.entry(\"abcd\", \"🔡\"),\n            Map.entry(\"accept\", \"🉑\"),\n            Map.entry(\"aerial_tramway\", \"🚡\"),\n            Map.entry(\"airplane\", \"✈\"),\n            Map.entry(\"airplane_arriving\", \"🛬\"),\n            Map.entry(\"airplane_departure\", \"🛫\"),\n            Map.entry(\"airplane_small\", \"🛩\"),\n            Map.entry(\"alarm_clock\", \"⏰\"),\n            Map.entry(\"alembic\", \"⚗\"),\n            Map.entry(\"alien\", \"👽\"),\n            Map.entry(\"ambulance\", \"🚑\"),\n            Map.entry(\"anchor\", \"⚓\"),\n            Map.entry(\"angel\", \"👼\"),\n            Map.entry(\"anger\", \"💢\"),\n            Map.entry(\"anger_right\", \"🗯\"),\n            Map.entry(\"angry\", \"😠\"),\n            Map.entry(\"ant\", \"🐜\"),\n            Map.entry(\"apple\", \"🍎\"),\n            Map.entry(\"aquarius\", \"♒\"),\n            Map.entry(\"aries\", \"♈\"),\n            Map.entry(\"arrow_backward\", \"◀\"),\n            Map.entry(\"arrow_double_down\", \"⏬\"),\n            Map.entry(\"arrow_double_up\", \"⏫\"),\n            Map.entry(\"arrow_down\", \"⬇\"),\n            Map.entry(\"arrow_down_small\", \"🔽\"),\n            Map.entry(\"arrow_forward\", \"▶\"),\n            Map.entry(\"arrow_heading_down\", \"⤵\"),\n            Map.entry(\"arrow_heading_up\", \"⤴\"),\n            Map.entry(\"arrow_left\", \"⬅\"),\n            Map.entry(\"arrow_lower_left\", \"↙\"),\n            Map.entry(\"arrow_lower_right\", \"↘\"),\n            Map.entry(\"arrow_right\", \"➡\"),\n            Map.entry(\"arrow_right_hook\", \"↪\"),\n            Map.entry(\"arrow_up\", \"⬆\"),\n            Map.entry(\"arrow_up_down\", \"↕\"),\n            Map.entry(\"arrow_up_small\", \"🔼\"),\n            Map.entry(\"arrow_upper_left\", \"↖\"),\n            Map.entry(\"arrow_upper_right\", \"↗\"),\n            Map.entry(\"arrows_clockwise\", \"🔃\"),\n            Map.entry(\"arrows_counterclockwise\", \"🔄\"),\n            Map.entry(\"art\", \"🎨\"),\n            Map.entry(\"articulated_lorry\", \"🚛\"),\n            Map.entry(\"asterisk\", \"*⃣\"),\n            Map.entry(\"astonished\", \"😲\"),\n            Map.entry(\"athletic_shoe\", \"👟\"),\n            Map.entry(\"atm\", \"🏧\"),\n            Map.entry(\"atom\", \"⚛\"),\n            Map.entry(\"b\", \"🅱\"),\n            Map.entry(\"baby\", \"👶\"),\n            Map.entry(\"baby_bottle\", \"🍼\"),\n            Map.entry(\"baby_chick\", \"🐤\"),\n            Map.entry(\"baby_symbol\", \"🚼\"),\n            Map.entry(\"back\", \"🔙\"),\n            Map.entry(\"baggage_claim\", \"🛄\"),\n            Map.entry(\"balloon\", \"🎈\"),\n            Map.entry(\"ballot_box\", \"🗳\"),\n            Map.entry(\"ballot_box_with_check\", \"☑\"),\n            Map.entry(\"bamboo\", \"🎍\"),\n            Map.entry(\"banana\", \"🍌\"),\n            Map.entry(\"bangbang\", \"‼\"),\n            Map.entry(\"bank\", \"🏦\"),\n            Map.entry(\"bar_chart\", \"📊\"),\n            Map.entry(\"barber\", \"💈\"),\n            Map.entry(\"baseball\", \"⚾\"),\n            Map.entry(\"basketball\", \"🏀\"),\n            Map.entry(\"basketball_player\", \"⛹\"),\n            Map.entry(\"bath\", \"🛀\"),\n            Map.entry(\"bathtub\", \"🛁\"),\n            Map.entry(\"battery\", \"🔋\"),\n            Map.entry(\"beach\", \"🏖\"),\n            Map.entry(\"beach_umbrella\", \"⛱\"),\n            Map.entry(\"bear\", \"🐻\"),\n            Map.entry(\"bed\", \"🛏\"),\n            Map.entry(\"bee\", \"🐝\"),\n            Map.entry(\"beer\", \"🍺\"),\n            Map.entry(\"beers\", \"🍻\"),\n            Map.entry(\"beetle\", \"🐞\"),\n            Map.entry(\"beginner\", \"🔰\"),\n            Map.entry(\"bell\", \"🔔\"),\n            Map.entry(\"bellhop\", \"🛎\"),\n            Map.entry(\"bento\", \"🍱\"),\n            Map.entry(\"bicyclist\", \"🚴\"),\n            Map.entry(\"bike\", \"🚲\"),\n            Map.entry(\"bikini\", \"👙\"),\n            Map.entry(\"biohazard\", \"☣\"),\n            Map.entry(\"bird\", \"🐦\"),\n            Map.entry(\"birthday\", \"🎂\"),\n            Map.entry(\"black_circle\", \"⚫\"),\n            Map.entry(\"black_joker\", \"🃏\"),\n            Map.entry(\"black_large_square\", \"⬛\"),\n            Map.entry(\"black_medium_small_square\", \"◾\"),\n            Map.entry(\"black_medium_square\", \"◼\"),\n            Map.entry(\"black_nib\", \"✒\"),\n            Map.entry(\"black_small_square\", \"▪\"),\n            Map.entry(\"black_square_button\", \"🔲\"),\n            Map.entry(\"blossom\", \"🌼\"),\n            Map.entry(\"blowfish\", \"🐡\"),\n            Map.entry(\"blue_book\", \"📘\"),\n            Map.entry(\"blue_car\", \"🚙\"),\n            Map.entry(\"blue_heart\", \"💙\"),\n            Map.entry(\"blush\", \"😊\"),\n            Map.entry(\"boar\", \"🐗\"),\n            Map.entry(\"bomb\", \"💣\"),\n            Map.entry(\"book\", \"📖\"),\n            Map.entry(\"bookmark\", \"🔖\"),\n            Map.entry(\"bookmark_tabs\", \"📑\"),\n            Map.entry(\"books\", \"📚\"),\n            Map.entry(\"boom\", \"💥\"),\n            Map.entry(\"boot\", \"👢\"),\n            Map.entry(\"bouquet\", \"💐\"),\n            Map.entry(\"bow\", \"🙇\"),\n            Map.entry(\"bowling\", \"🎳\"),\n            Map.entry(\"boy\", \"👦\"),\n            Map.entry(\"bread\", \"🍞\"),\n            Map.entry(\"bride_with_veil\", \"👰\"),\n            Map.entry(\"bridge_at_night\", \"🌉\"),\n            Map.entry(\"briefcase\", \"💼\"),\n            Map.entry(\"broken_heart\", \"💔\"),\n            Map.entry(\"bug\", \"🐛\"),\n            Map.entry(\"bulb\", \"💡\"),\n            Map.entry(\"bullettrain_front\", \"🚅\"),\n            Map.entry(\"bullettrain_side\", \"🚄\"),\n            Map.entry(\"bus\", \"🚌\"),\n            Map.entry(\"busstop\", \"🚏\"),\n            Map.entry(\"bust_in_silhouette\", \"👤\"),\n            Map.entry(\"busts_in_silhouette\", \"👥\"),\n            Map.entry(\"cactus\", \"🌵\"),\n            Map.entry(\"cake\", \"🍰\"),\n            Map.entry(\"calendar\", \"📆\"),\n            Map.entry(\"calendar_spiral\", \"🗓\"),\n            Map.entry(\"calling\", \"📲\"),\n            Map.entry(\"camel\", \"🐫\"),\n            Map.entry(\"camera\", \"📷\"),\n            Map.entry(\"camera_with_flash\", \"📸\"),\n            Map.entry(\"camping\", \"🏕\"),\n            Map.entry(\"cancer\", \"♋\"),\n            Map.entry(\"candle\", \"🕯\"),\n            Map.entry(\"candy\", \"🍬\"),\n            Map.entry(\"capital_abcd\", \"🔠\"),\n            Map.entry(\"capricorn\", \"♑\"),\n            Map.entry(\"card_box\", \"🗃\"),\n            Map.entry(\"card_index\", \"📇\"),\n            Map.entry(\"carousel_horse\", \"🎠\"),\n            Map.entry(\"cat\", \"🐱\"),\n            Map.entry(\"cat2\", \"🐈\"),\n            Map.entry(\"cd\", \"💿\"),\n            Map.entry(\"chains\", \"⛓\"),\n            Map.entry(\"chart\", \"💹\"),\n            Map.entry(\"chart_with_downwards_trend\", \"📉\"),\n            Map.entry(\"chart_with_upwards_trend\", \"📈\"),\n            Map.entry(\"checkered_flag\", \"🏁\"),\n            Map.entry(\"cherries\", \"🍒\"),\n            Map.entry(\"cherry_blossom\", \"🌸\"),\n            Map.entry(\"chestnut\", \"🌰\"),\n            Map.entry(\"chicken\", \"🐔\"),\n            Map.entry(\"children_crossing\", \"🚸\"),\n            Map.entry(\"chipmunk\", \"🐿\"),\n            Map.entry(\"chocolate_bar\", \"🍫\"),\n            Map.entry(\"christmas_tree\", \"🎄\"),\n            Map.entry(\"church\", \"⛪\"),\n            Map.entry(\"cinema\", \"🎦\"),\n            Map.entry(\"circus_tent\", \"🎪\"),\n            Map.entry(\"city_dusk\", \"🌆\"),\n            Map.entry(\"city_sunset\", \"🌇\"),\n            Map.entry(\"cityscape\", \"🏙\"),\n            Map.entry(\"cl\", \"🆑\"),\n            Map.entry(\"clap\", \"👏\"),\n            Map.entry(\"clapper\", \"🎬\"),\n            Map.entry(\"classical_building\", \"🏛\"),\n            Map.entry(\"clipboard\", \"📋\"),\n            Map.entry(\"clock\", \"🕰\"),\n            Map.entry(\"clock1\", \"🕐\"),\n            Map.entry(\"clock10\", \"🕙\"),\n            Map.entry(\"clock1030\", \"🕥\"),\n            Map.entry(\"clock11\", \"🕚\"),\n            Map.entry(\"clock1130\", \"🕦\"),\n            Map.entry(\"clock12\", \"🕛\"),\n            Map.entry(\"clock1230\", \"🕧\"),\n            Map.entry(\"clock130\", \"🕜\"),\n            Map.entry(\"clock2\", \"🕑\"),\n            Map.entry(\"clock230\", \"🕝\"),\n            Map.entry(\"clock3\", \"🕒\"),\n            Map.entry(\"clock330\", \"🕞\"),\n            Map.entry(\"clock4\", \"🕓\"),\n            Map.entry(\"clock430\", \"🕟\"),\n            Map.entry(\"clock5\", \"🕔\"),\n            Map.entry(\"clock530\", \"🕠\"),\n            Map.entry(\"clock6\", \"🕕\"),\n            Map.entry(\"clock630\", \"🕡\"),\n            Map.entry(\"clock7\", \"🕖\"),\n            Map.entry(\"clock730\", \"🕢\"),\n            Map.entry(\"clock8\", \"🕗\"),\n            Map.entry(\"clock830\", \"🕣\"),\n            Map.entry(\"clock9\", \"🕘\"),\n            Map.entry(\"clock930\", \"🕤\"),\n            Map.entry(\"closed_book\", \"📕\"),\n            Map.entry(\"closed_lock_with_key\", \"🔐\"),\n            Map.entry(\"closed_umbrella\", \"🌂\"),\n            Map.entry(\"cloud\", \"☁\"),\n            Map.entry(\"cloud_lightning\", \"🌩\"),\n            Map.entry(\"cloud_rain\", \"🌧\"),\n            Map.entry(\"cloud_snow\", \"🌨\"),\n            Map.entry(\"cloud_tornado\", \"🌪\"),\n            Map.entry(\"clubs\", \"♣\"),\n            Map.entry(\"cocktail\", \"🍸\"),\n            Map.entry(\"coffee\", \"☕\"),\n            Map.entry(\"coffin\", \"⚰\"),\n            Map.entry(\"cold_sweat\", \"😰\"),\n            Map.entry(\"comet\", \"☄\"),\n            Map.entry(\"compression\", \"🗜\"),\n            Map.entry(\"computer\", \"💻\"),\n            Map.entry(\"confetti_ball\", \"🎊\"),\n            Map.entry(\"confounded\", \"😖\"),\n            Map.entry(\"confused\", \"😕\"),\n            Map.entry(\"congratulations\", \"㊗\"),\n            Map.entry(\"construction\", \"🚧\"),\n            Map.entry(\"construction_site\", \"🏗\"),\n            Map.entry(\"construction_worker\", \"👷\"),\n            Map.entry(\"control_knobs\", \"🎛\"),\n            Map.entry(\"convenience_store\", \"🏪\"),\n            Map.entry(\"cookie\", \"🍪\"),\n            Map.entry(\"cooking\", \"🍳\"),\n            Map.entry(\"cool\", \"🆒\"),\n            Map.entry(\"cop\", \"👮\"),\n            Map.entry(\"copyright\", \"©\"),\n            Map.entry(\"corn\", \"🌽\"),\n            Map.entry(\"couch\", \"🛋\"),\n            Map.entry(\"couple\", \"👫\"),\n            Map.entry(\"couple_mm\", \"👨‍❤️‍👨\"),\n            Map.entry(\"couple_with_heart\", \"💑\"),\n            Map.entry(\"couple_ww\", \"👩‍❤️‍👩\"),\n            Map.entry(\"couplekiss\", \"💏\"),\n            Map.entry(\"cow\", \"🐮\"),\n            Map.entry(\"cow2\", \"🐄\"),\n            Map.entry(\"crayon\", \"🖍\"),\n            Map.entry(\"credit_card\", \"💳\"),\n            Map.entry(\"crescent_moon\", \"🌙\"),\n            Map.entry(\"crocodile\", \"🐊\"),\n            Map.entry(\"cross\", \"✝\"),\n            Map.entry(\"crossed_flags\", \"🎌\"),\n            Map.entry(\"crossed_swords\", \"⚔\"),\n            Map.entry(\"crown\", \"👑\"),\n            Map.entry(\"cruise_ship\", \"🛳\"),\n            Map.entry(\"cry\", \"😢\"),\n            Map.entry(\"crying_cat_face\", \"😿\"),\n            Map.entry(\"crystal_ball\", \"🔮\"),\n            Map.entry(\"cupid\", \"💘\"),\n            Map.entry(\"curly_loop\", \"➰\"),\n            Map.entry(\"currency_exchange\", \"💱\"),\n            Map.entry(\"curry\", \"🍛\"),\n            Map.entry(\"custard\", \"🍮\"),\n            Map.entry(\"customs\", \"🛃\"),\n            Map.entry(\"cyclone\", \"🌀\"),\n            Map.entry(\"dagger\", \"🗡\"),\n            Map.entry(\"dancer\", \"💃\"),\n            Map.entry(\"dancers\", \"👯\"),\n            Map.entry(\"dango\", \"🍡\"),\n            Map.entry(\"dark_sunglasses\", \"🕶\"),\n            Map.entry(\"dart\", \"🎯\"),\n            Map.entry(\"dash\", \"💨\"),\n            Map.entry(\"date\", \"📅\"),\n            Map.entry(\"deciduous_tree\", \"🌳\"),\n            Map.entry(\"department_store\", \"🏬\"),\n            Map.entry(\"desert\", \"🏜\"),\n            Map.entry(\"desktop\", \"🖥\"),\n            Map.entry(\"diamond_shape_with_a_dot_inside\", \"💠\"),\n            Map.entry(\"diamonds\", \"♦\"),\n            Map.entry(\"disappointed\", \"😞\"),\n            Map.entry(\"disappointed_relieved\", \"😥\"),\n            Map.entry(\"dividers\", \"🗂\"),\n            Map.entry(\"dizzy\", \"💫\"),\n            Map.entry(\"dizzy_face\", \"😵\"),\n            Map.entry(\"do_not_litter\", \"🚯\"),\n            Map.entry(\"dog\", \"🐶\"),\n            Map.entry(\"dog2\", \"🐕\"),\n            Map.entry(\"dollar\", \"💵\"),\n            Map.entry(\"dolls\", \"🎎\"),\n            Map.entry(\"dolphin\", \"🐬\"),\n            Map.entry(\"door\", \"🚪\"),\n            Map.entry(\"doughnut\", \"🍩\"),\n            Map.entry(\"dove\", \"🕊\"),\n            Map.entry(\"dragon\", \"🐉\"),\n            Map.entry(\"dragon_face\", \"🐲\"),\n            Map.entry(\"dress\", \"👗\"),\n            Map.entry(\"dromedary_camel\", \"🐪\"),\n            Map.entry(\"droplet\", \"💧\"),\n            Map.entry(\"dvd\", \"📀\"),\n            Map.entry(\"e-mail\", \"📧\"),\n            Map.entry(\"ear\", \"👂\"),\n            Map.entry(\"ear_of_rice\", \"🌾\"),\n            Map.entry(\"earth_africa\", \"🌍\"),\n            Map.entry(\"earth_americas\", \"🌎\"),\n            Map.entry(\"earth_asia\", \"🌏\"),\n            Map.entry(\"eggplant\", \"🍆\"),\n            Map.entry(\"eight\", \"8️⃣\"),\n            Map.entry(\"eight_pointed_black_star\", \"✴\"),\n            Map.entry(\"eight_spoked_asterisk\", \"✳\"),\n            Map.entry(\"eject\", \"⏏\"),\n            Map.entry(\"electric_plug\", \"🔌\"),\n            Map.entry(\"elephant\", \"🐘\"),\n            Map.entry(\"end\", \"🔚\"),\n            Map.entry(\"envelope\", \"✉\"),\n            Map.entry(\"envelope_with_arrow\", \"📩\"),\n            Map.entry(\"euro\", \"💶\"),\n            Map.entry(\"european_castle\", \"🏰\"),\n            Map.entry(\"european_post_office\", \"🏤\"),\n            Map.entry(\"evergreen_tree\", \"🌲\"),\n            Map.entry(\"exclamation\", \"❗\"),\n            Map.entry(\"expressionless\", \"😑\"),\n            Map.entry(\"eye\", \"👁\"),\n            Map.entry(\"eye_in_speech_bubble\", \"👁‍🗨\"),\n            Map.entry(\"eyeglasses\", \"👓\"),\n            Map.entry(\"eyes\", \"👀\"),\n            Map.entry(\"factory\", \"🏭\"),\n            Map.entry(\"fallen_leaf\", \"🍂\"),\n            Map.entry(\"family\", \"👪\"),\n            Map.entry(\"family_mmb\", \"👨‍👨‍👦\"),\n            Map.entry(\"family_mmbb\", \"👨‍👨‍👦‍👦\"),\n            Map.entry(\"family_mmg\", \"👨‍👨‍👧\"),\n            Map.entry(\"family_mmgb\", \"👨‍👨‍👧‍👦\"),\n            Map.entry(\"family_mmgg\", \"👨‍👨‍👧‍👧\"),\n            Map.entry(\"family_mwbb\", \"👨‍👩‍👦‍👦\"),\n            Map.entry(\"family_mwg\", \"👨‍👩‍👧\"),\n            Map.entry(\"family_mwgb\", \"👨‍👩‍👧‍👦\"),\n            Map.entry(\"family_mwgg\", \"👨‍👩‍👧‍👧\"),\n            Map.entry(\"family_wwb\", \"👩‍👩‍👦\"),\n            Map.entry(\"family_wwbb\", \"👩‍👩‍👦‍👦\"),\n            Map.entry(\"family_wwg\", \"👩‍👩‍👧\"),\n            Map.entry(\"family_wwgb\", \"👩‍👩‍👧‍👦\"),\n            Map.entry(\"family_wwgg\", \"👩‍👩‍👧‍👧\"),\n            Map.entry(\"fast_forward\", \"⏩\"),\n            Map.entry(\"fax\", \"📠\"),\n            Map.entry(\"fearful\", \"😨\"),\n            Map.entry(\"feet\", \"🐾\"),\n            Map.entry(\"ferris_wheel\", \"🎡\"),\n            Map.entry(\"ferry\", \"⛴\"),\n            Map.entry(\"file_cabinet\", \"🗄\"),\n            Map.entry(\"file_folder\", \"📁\"),\n            Map.entry(\"film_frames\", \"🎞\"),\n            Map.entry(\"fire\", \"🔥\"),\n            Map.entry(\"fire_engine\", \"🚒\"),\n            Map.entry(\"fireworks\", \"🎆\"),\n            Map.entry(\"first_quarter_moon\", \"🌓\"),\n            Map.entry(\"first_quarter_moon_with_face\", \"🌛\"),\n            Map.entry(\"fish\", \"🐟\"),\n            Map.entry(\"fish_cake\", \"🍥\"),\n            Map.entry(\"fishing_pole_and_fish\", \"🎣\"),\n            Map.entry(\"fist\", \"✊\"),\n            Map.entry(\"five\", \"5️⃣\"),\n            Map.entry(\"flag_ac\", \"🇦🇨\"),\n            Map.entry(\"flag_ad\", \"🇦🇩\"),\n            Map.entry(\"flag_ae\", \"🇦🇪\"),\n            Map.entry(\"flag_af\", \"🇦🇫\"),\n            Map.entry(\"flag_ag\", \"🇦🇬\"),\n            Map.entry(\"flag_ai\", \"🇦🇮\"),\n            Map.entry(\"flag_al\", \"🇦🇱\"),\n            Map.entry(\"flag_am\", \"🇦🇲\"),\n            Map.entry(\"flag_ao\", \"🇦🇴\"),\n            Map.entry(\"flag_aq\", \"🇦🇶\"),\n            Map.entry(\"flag_ar\", \"🇦🇷\"),\n            Map.entry(\"flag_as\", \"🇦🇸\"),\n            Map.entry(\"flag_at\", \"🇦🇹\"),\n            Map.entry(\"flag_au\", \"🇦🇺\"),\n            Map.entry(\"flag_aw\", \"🇦🇼\"),\n            Map.entry(\"flag_ax\", \"🇦🇽\"),\n            Map.entry(\"flag_az\", \"🇦🇿\"),\n            Map.entry(\"flag_ba\", \"🇧🇦\"),\n            Map.entry(\"flag_bb\", \"🇧🇧\"),\n            Map.entry(\"flag_bd\", \"🇧🇩\"),\n            Map.entry(\"flag_be\", \"🇧🇪\"),\n            Map.entry(\"flag_bf\", \"🇧🇫\"),\n            Map.entry(\"flag_bg\", \"🇧🇬\"),\n            Map.entry(\"flag_bh\", \"🇧🇭\"),\n            Map.entry(\"flag_bi\", \"🇧🇮\"),\n            Map.entry(\"flag_bj\", \"🇧🇯\"),\n            Map.entry(\"flag_bl\", \"🇧🇱\"),\n            Map.entry(\"flag_black\", \"🏴\"),\n            Map.entry(\"flag_bm\", \"🇧🇲\"),\n            Map.entry(\"flag_bn\", \"🇧🇳\"),\n            Map.entry(\"flag_bo\", \"🇧🇴\"),\n            Map.entry(\"flag_bq\", \"🇧🇶\"),\n            Map.entry(\"flag_br\", \"🇧🇷\"),\n            Map.entry(\"flag_bs\", \"🇧🇸\"),\n            Map.entry(\"flag_bt\", \"🇧🇹\"),\n            Map.entry(\"flag_bv\", \"🇧🇻\"),\n            Map.entry(\"flag_bw\", \"🇧🇼\"),\n            Map.entry(\"flag_by\", \"🇧🇾\"),\n            Map.entry(\"flag_bz\", \"🇧🇿\"),\n            Map.entry(\"flag_ca\", \"🇨🇦\"),\n            Map.entry(\"flag_cc\", \"🇨🇨\"),\n            Map.entry(\"flag_cd\", \"🇨🇩\"),\n            Map.entry(\"flag_cf\", \"🇨🇫\"),\n            Map.entry(\"flag_cg\", \"🇨🇬\"),\n            Map.entry(\"flag_ch\", \"🇨🇭\"),\n            Map.entry(\"flag_ci\", \"🇨🇮\"),\n            Map.entry(\"flag_ck\", \"🇨🇰\"),\n            Map.entry(\"flag_cl\", \"🇨🇱\"),\n            Map.entry(\"flag_cm\", \"🇨🇲\"),\n            Map.entry(\"flag_cn\", \"🇨🇳\"),\n            Map.entry(\"flag_co\", \"🇨🇴\"),\n            Map.entry(\"flag_cp\", \"🇨🇵\"),\n            Map.entry(\"flag_cr\", \"🇨🇷\"),\n            Map.entry(\"flag_cu\", \"🇨🇺\"),\n            Map.entry(\"flag_cv\", \"🇨🇻\"),\n            Map.entry(\"flag_cw\", \"🇨🇼\"),\n            Map.entry(\"flag_cx\", \"🇨🇽\"),\n            Map.entry(\"flag_cy\", \"🇨🇾\"),\n            Map.entry(\"flag_cz\", \"🇨🇿\"),\n            Map.entry(\"flag_de\", \"🇩🇪\"),\n            Map.entry(\"flag_dg\", \"🇩🇬\"),\n            Map.entry(\"flag_dj\", \"🇩🇯\"),\n            Map.entry(\"flag_dk\", \"🇩🇰\"),\n            Map.entry(\"flag_dm\", \"🇩🇲\"),\n            Map.entry(\"flag_do\", \"🇩🇴\"),\n            Map.entry(\"flag_dz\", \"🇩🇿\"),\n            Map.entry(\"flag_ea\", \"🇪🇦\"),\n            Map.entry(\"flag_ec\", \"🇪🇨\"),\n            Map.entry(\"flag_ee\", \"🇪🇪\"),\n            Map.entry(\"flag_eg\", \"🇪🇬\"),\n            Map.entry(\"flag_eh\", \"🇪🇭\"),\n            Map.entry(\"flag_er\", \"🇪🇷\"),\n            Map.entry(\"flag_es\", \"🇪🇸\"),\n            Map.entry(\"flag_et\", \"🇪🇹\"),\n            Map.entry(\"flag_eu\", \"🇪🇺\"),\n            Map.entry(\"flag_fi\", \"🇫🇮\"),\n            Map.entry(\"flag_fj\", \"🇫🇯\"),\n            Map.entry(\"flag_fk\", \"🇫🇰\"),\n            Map.entry(\"flag_fm\", \"🇫🇲\"),\n            Map.entry(\"flag_fo\", \"🇫🇴\"),\n            Map.entry(\"flag_fr\", \"🇫🇷\"),\n            Map.entry(\"flag_ga\", \"🇬🇦\"),\n            Map.entry(\"flag_gb\", \"🇬🇧\"),\n            Map.entry(\"flag_gd\", \"🇬🇩\"),\n            Map.entry(\"flag_ge\", \"🇬🇪\"),\n            Map.entry(\"flag_gf\", \"🇬🇫\"),\n            Map.entry(\"flag_gg\", \"🇬🇬\"),\n            Map.entry(\"flag_gh\", \"🇬🇭\"),\n            Map.entry(\"flag_gi\", \"🇬🇮\"),\n            Map.entry(\"flag_gl\", \"🇬🇱\"),\n            Map.entry(\"flag_gm\", \"🇬🇲\"),\n            Map.entry(\"flag_gn\", \"🇬🇳\"),\n            Map.entry(\"flag_gp\", \"🇬🇵\"),\n            Map.entry(\"flag_gq\", \"🇬🇶\"),\n            Map.entry(\"flag_gr\", \"🇬🇷\"),\n            Map.entry(\"flag_gs\", \"🇬🇸\"),\n            Map.entry(\"flag_gt\", \"🇬🇹\"),\n            Map.entry(\"flag_gu\", \"🇬🇺\"),\n            Map.entry(\"flag_gw\", \"🇬🇼\"),\n            Map.entry(\"flag_gy\", \"🇬🇾\"),\n            Map.entry(\"flag_hk\", \"🇭🇰\"),\n            Map.entry(\"flag_hm\", \"🇭🇲\"),\n            Map.entry(\"flag_hn\", \"🇭🇳\"),\n            Map.entry(\"flag_hr\", \"🇭🇷\"),\n            Map.entry(\"flag_ht\", \"🇭🇹\"),\n            Map.entry(\"flag_hu\", \"🇭🇺\"),\n            Map.entry(\"flag_ic\", \"🇮🇨\"),\n            Map.entry(\"flag_id\", \"🇮🇩\"),\n            Map.entry(\"flag_ie\", \"🇮🇪\"),\n            Map.entry(\"flag_il\", \"🇮🇱\"),\n            Map.entry(\"flag_im\", \"🇮🇲\"),\n            Map.entry(\"flag_in\", \"🇮🇳\"),\n            Map.entry(\"flag_io\", \"🇮🇴\"),\n            Map.entry(\"flag_iq\", \"🇮🇶\"),\n            Map.entry(\"flag_ir\", \"🇮🇷\"),\n            Map.entry(\"flag_is\", \"🇮🇸\"),\n            Map.entry(\"flag_it\", \"🇮🇹\"),\n            Map.entry(\"flag_je\", \"🇯🇪\"),\n            Map.entry(\"flag_jm\", \"🇯🇲\"),\n            Map.entry(\"flag_jo\", \"🇯🇴\"),\n            Map.entry(\"flag_jp\", \"🇯🇵\"),\n            Map.entry(\"flag_ke\", \"🇰🇪\"),\n            Map.entry(\"flag_kg\", \"🇰🇬\"),\n            Map.entry(\"flag_kh\", \"🇰🇭\"),\n            Map.entry(\"flag_ki\", \"🇰🇮\"),\n            Map.entry(\"flag_km\", \"🇰🇲\"),\n            Map.entry(\"flag_kn\", \"🇰🇳\"),\n            Map.entry(\"flag_kp\", \"🇰🇵\"),\n            Map.entry(\"flag_kr\", \"🇰🇷\"),\n            Map.entry(\"flag_kw\", \"🇰🇼\"),\n            Map.entry(\"flag_ky\", \"🇰🇾\"),\n            Map.entry(\"flag_kz\", \"🇰🇿\"),\n            Map.entry(\"flag_la\", \"🇱🇦\"),\n            Map.entry(\"flag_lb\", \"🇱🇧\"),\n            Map.entry(\"flag_lc\", \"🇱🇨\"),\n            Map.entry(\"flag_li\", \"🇱🇮\"),\n            Map.entry(\"flag_lk\", \"🇱🇰\"),\n            Map.entry(\"flag_lr\", \"🇱🇷\"),\n            Map.entry(\"flag_ls\", \"🇱🇸\"),\n            Map.entry(\"flag_lt\", \"🇱🇹\"),\n            Map.entry(\"flag_lu\", \"🇱🇺\"),\n            Map.entry(\"flag_lv\", \"🇱🇻\"),\n            Map.entry(\"flag_ly\", \"🇱🇾\"),\n            Map.entry(\"flag_ma\", \"🇲🇦\"),\n            Map.entry(\"flag_mc\", \"🇲🇨\"),\n            Map.entry(\"flag_md\", \"🇲🇩\"),\n            Map.entry(\"flag_me\", \"🇲🇪\"),\n            Map.entry(\"flag_mf\", \"🇲🇫\"),\n            Map.entry(\"flag_mg\", \"🇲🇬\"),\n            Map.entry(\"flag_mh\", \"🇲🇭\"),\n            Map.entry(\"flag_mk\", \"🇲🇰\"),\n            Map.entry(\"flag_ml\", \"🇲🇱\"),\n            Map.entry(\"flag_mm\", \"🇲🇲\"),\n            Map.entry(\"flag_mn\", \"🇲🇳\"),\n            Map.entry(\"flag_mo\", \"🇲🇴\"),\n            Map.entry(\"flag_mp\", \"🇲🇵\"),\n            Map.entry(\"flag_mq\", \"🇲🇶\"),\n            Map.entry(\"flag_mr\", \"🇲🇷\"),\n            Map.entry(\"flag_ms\", \"🇲🇸\"),\n            Map.entry(\"flag_mt\", \"🇲🇹\"),\n            Map.entry(\"flag_mu\", \"🇲🇺\"),\n            Map.entry(\"flag_mv\", \"🇲🇻\"),\n            Map.entry(\"flag_mw\", \"🇲🇼\"),\n            Map.entry(\"flag_mx\", \"🇲🇽\"),\n            Map.entry(\"flag_my\", \"🇲🇾\"),\n            Map.entry(\"flag_mz\", \"🇲🇿\"),\n            Map.entry(\"flag_na\", \"🇳🇦\"),\n            Map.entry(\"flag_nc\", \"🇳🇨\"),\n            Map.entry(\"flag_ne\", \"🇳🇪\"),\n            Map.entry(\"flag_nf\", \"🇳🇫\"),\n            Map.entry(\"flag_ng\", \"🇳🇬\"),\n            Map.entry(\"flag_ni\", \"🇳🇮\"),\n            Map.entry(\"flag_nl\", \"🇳🇱\"),\n            Map.entry(\"flag_no\", \"🇳🇴\"),\n            Map.entry(\"flag_np\", \"🇳🇵\"),\n            Map.entry(\"flag_nr\", \"🇳🇷\"),\n            Map.entry(\"flag_nu\", \"🇳🇺\"),\n            Map.entry(\"flag_nz\", \"🇳🇿\"),\n            Map.entry(\"flag_om\", \"🇴🇲\"),\n            Map.entry(\"flag_pa\", \"🇵🇦\"),\n            Map.entry(\"flag_pe\", \"🇵🇪\"),\n            Map.entry(\"flag_pf\", \"🇵🇫\"),\n            Map.entry(\"flag_pg\", \"🇵🇬\"),\n            Map.entry(\"flag_ph\", \"🇵🇭\"),\n            Map.entry(\"flag_pk\", \"🇵🇰\"),\n            Map.entry(\"flag_pl\", \"🇵🇱\"),\n            Map.entry(\"flag_pm\", \"🇵🇲\"),\n            Map.entry(\"flag_pn\", \"🇵🇳\"),\n            Map.entry(\"flag_pr\", \"🇵🇷\"),\n            Map.entry(\"flag_ps\", \"🇵🇸\"),\n            Map.entry(\"flag_pt\", \"🇵🇹\"),\n            Map.entry(\"flag_pw\", \"🇵🇼\"),\n            Map.entry(\"flag_py\", \"🇵🇾\"),\n            Map.entry(\"flag_qa\", \"🇶🇦\"),\n            Map.entry(\"flag_re\", \"🇷🇪\"),\n            Map.entry(\"flag_ro\", \"🇷🇴\"),\n            Map.entry(\"flag_rs\", \"🇷🇸\"),\n            Map.entry(\"flag_ru\", \"🇷🇺\"),\n            Map.entry(\"flag_rw\", \"🇷🇼\"),\n            Map.entry(\"flag_sa\", \"🇸🇦\"),\n            Map.entry(\"flag_sb\", \"🇸🇧\"),\n            Map.entry(\"flag_sc\", \"🇸🇨\"),\n            Map.entry(\"flag_sd\", \"🇸🇩\"),\n            Map.entry(\"flag_se\", \"🇸🇪\"),\n            Map.entry(\"flag_sg\", \"🇸🇬\"),\n            Map.entry(\"flag_sh\", \"🇸🇭\"),\n            Map.entry(\"flag_si\", \"🇸🇮\"),\n            Map.entry(\"flag_sj\", \"🇸🇯\"),\n            Map.entry(\"flag_sk\", \"🇸🇰\"),\n            Map.entry(\"flag_sl\", \"🇸🇱\"),\n            Map.entry(\"flag_sm\", \"🇸🇲\"),\n            Map.entry(\"flag_sn\", \"🇸🇳\"),\n            Map.entry(\"flag_so\", \"🇸🇴\"),\n            Map.entry(\"flag_sr\", \"🇸🇷\"),\n            Map.entry(\"flag_ss\", \"🇸🇸\"),\n            Map.entry(\"flag_st\", \"🇸🇹\"),\n            Map.entry(\"flag_sv\", \"🇸🇻\"),\n            Map.entry(\"flag_sx\", \"🇸🇽\"),\n            Map.entry(\"flag_sy\", \"🇸🇾\"),\n            Map.entry(\"flag_sz\", \"🇸🇿\"),\n            Map.entry(\"flag_ta\", \"🇹🇦\"),\n            Map.entry(\"flag_tc\", \"🇹🇨\"),\n            Map.entry(\"flag_td\", \"🇹🇩\"),\n            Map.entry(\"flag_tf\", \"🇹🇫\"),\n            Map.entry(\"flag_tg\", \"🇹🇬\"),\n            Map.entry(\"flag_th\", \"🇹🇭\"),\n            Map.entry(\"flag_tj\", \"🇹🇯\"),\n            Map.entry(\"flag_tk\", \"🇹🇰\"),\n            Map.entry(\"flag_tl\", \"🇹🇱\"),\n            Map.entry(\"flag_tm\", \"🇹🇲\"),\n            Map.entry(\"flag_tn\", \"🇹🇳\"),\n            Map.entry(\"flag_to\", \"🇹🇴\"),\n            Map.entry(\"flag_tr\", \"🇹🇷\"),\n            Map.entry(\"flag_tt\", \"🇹🇹\"),\n            Map.entry(\"flag_tv\", \"🇹🇻\"),\n            Map.entry(\"flag_tw\", \"🇹🇼\"),\n            Map.entry(\"flag_tz\", \"🇹🇿\"),\n            Map.entry(\"flag_ua\", \"🇺🇦\"),\n            Map.entry(\"flag_ug\", \"🇺🇬\"),\n            Map.entry(\"flag_um\", \"🇺🇲\"),\n            Map.entry(\"flag_us\", \"🇺🇸\"),\n            Map.entry(\"flag_uy\", \"🇺🇾\"),\n            Map.entry(\"flag_uz\", \"🇺🇿\"),\n            Map.entry(\"flag_va\", \"🇻🇦\"),\n            Map.entry(\"flag_vc\", \"🇻🇨\"),\n            Map.entry(\"flag_ve\", \"🇻🇪\"),\n            Map.entry(\"flag_vg\", \"🇻🇬\"),\n            Map.entry(\"flag_vi\", \"🇻🇮\"),\n            Map.entry(\"flag_vn\", \"🇻🇳\"),\n            Map.entry(\"flag_vu\", \"🇻🇺\"),\n            Map.entry(\"flag_wf\", \"🇼🇫\"),\n            Map.entry(\"flag_white\", \"🏳\"),\n            Map.entry(\"flag_ws\", \"🇼🇸\"),\n            Map.entry(\"flag_xk\", \"🇽🇰\"),\n            Map.entry(\"flag_ye\", \"🇾🇪\"),\n            Map.entry(\"flag_yt\", \"🇾🇹\"),\n            Map.entry(\"flag_za\", \"🇿🇦\"),\n            Map.entry(\"flag_zm\", \"🇿🇲\"),\n            Map.entry(\"flag_zw\", \"🇿🇼\"),\n            Map.entry(\"flags\", \"🎏\"),\n            Map.entry(\"flashlight\", \"🔦\"),\n            Map.entry(\"fleur-de-lis\", \"⚜\"),\n            Map.entry(\"floppy_disk\", \"💾\"),\n            Map.entry(\"flower_playing_cards\", \"🎴\"),\n            Map.entry(\"flushed\", \"😳\"),\n            Map.entry(\"fog\", \"🌫\"),\n            Map.entry(\"foggy\", \"🌁\"),\n            Map.entry(\"football\", \"🏈\"),\n            Map.entry(\"footprints\", \"👣\"),\n            Map.entry(\"fork_and_knife\", \"🍴\"),\n            Map.entry(\"fork_knife_plate\", \"🍽\"),\n            Map.entry(\"fountain\", \"⛲\"),\n            Map.entry(\"four\", \"4️⃣\"),\n            Map.entry(\"four_leaf_clover\", \"🍀\"),\n            Map.entry(\"frame_photo\", \"🖼\"),\n            Map.entry(\"free\", \"🆓\"),\n            Map.entry(\"fried_shrimp\", \"🍤\"),\n            Map.entry(\"fries\", \"🍟\"),\n            Map.entry(\"frog\", \"🐸\"),\n            Map.entry(\"frowning\", \"😦\"),\n            Map.entry(\"frowning2\", \"☹\"),\n            Map.entry(\"fuelpump\", \"⛽\"),\n            Map.entry(\"full_moon\", \"🌕\"),\n            Map.entry(\"full_moon_with_face\", \"🌝\"),\n            Map.entry(\"game_die\", \"🎲\"),\n            Map.entry(\"gear\", \"⚙\"),\n            Map.entry(\"gem\", \"💎\"),\n            Map.entry(\"gay_pride_flag\", \"🏳🌈\"),\n            Map.entry(\"gemini\", \"♊\"),\n            Map.entry(\"ghost\", \"👻\"),\n            Map.entry(\"gift\", \"🎁\"),\n            Map.entry(\"gift_heart\", \"💝\"),\n            Map.entry(\"girl\", \"👧\"),\n            Map.entry(\"globe_with_meridians\", \"🌐\"),\n            Map.entry(\"goat\", \"🐐\"),\n            Map.entry(\"golf\", \"⛳\"),\n            Map.entry(\"golfer\", \"🏌\"),\n            Map.entry(\"grapes\", \"🍇\"),\n            Map.entry(\"green_apple\", \"🍏\"),\n            Map.entry(\"green_book\", \"📗\"),\n            Map.entry(\"green_heart\", \"💚\"),\n            Map.entry(\"grey_exclamation\", \"❕\"),\n            Map.entry(\"grey_question\", \"❔\"),\n            Map.entry(\"grimacing\", \"😬\"),\n            Map.entry(\"grin\", \"😁\"),\n            Map.entry(\"grinning\", \"😀\"),\n            Map.entry(\"guardsman\", \"💂\"),\n            Map.entry(\"guitar\", \"🎸\"),\n            Map.entry(\"gun\", \"🔫\"),\n            Map.entry(\"haircut\", \"💇\"),\n            Map.entry(\"hamburger\", \"🍔\"),\n            Map.entry(\"hammer\", \"🔨\"),\n            Map.entry(\"hammer_pick\", \"⚒\"),\n            Map.entry(\"hamster\", \"🐹\"),\n            Map.entry(\"hand_splayed\", \"🖐\"),\n            Map.entry(\"handbag\", \"👜\"),\n            Map.entry(\"hash\", \"#⃣\"),\n            Map.entry(\"hatched_chick\", \"🐥\"),\n            Map.entry(\"hatching_chick\", \"🐣\"),\n            Map.entry(\"headphones\", \"🎧\"),\n            Map.entry(\"hear_no_evil\", \"🙉\"),\n            Map.entry(\"heart\", \"❤\"),\n            Map.entry(\"heart_decoration\", \"💟\"),\n            Map.entry(\"heart_exclamation\", \"❣\"),\n            Map.entry(\"heart_eyes\", \"😍\"),\n            Map.entry(\"heart_eyes_cat\", \"😻\"),\n            Map.entry(\"heartbeat\", \"💓\"),\n            Map.entry(\"heartpulse\", \"💗\"),\n            Map.entry(\"hearts\", \"♥\"),\n            Map.entry(\"heavy_check_mark\", \"✔\"),\n            Map.entry(\"heavy_division_sign\", \"➗\"),\n            Map.entry(\"heavy_dollar_sign\", \"💲\"),\n            Map.entry(\"heavy_minus_sign\", \"➖\"),\n            Map.entry(\"heavy_multiplication_x\", \"✖\"),\n            Map.entry(\"heavy_plus_sign\", \"➕\"),\n            Map.entry(\"helicopter\", \"🚁\"),\n            Map.entry(\"helmet_with_cross\", \"⛑\"),\n            Map.entry(\"herb\", \"🌿\"),\n            Map.entry(\"hibiscus\", \"🌺\"),\n            Map.entry(\"high_brightness\", \"🔆\"),\n            Map.entry(\"high_heel\", \"👠\"),\n            Map.entry(\"hole\", \"🕳\"),\n            Map.entry(\"homes\", \"🏘\"),\n            Map.entry(\"honey_pot\", \"🍯\"),\n            Map.entry(\"horse\", \"🐴\"),\n            Map.entry(\"horse_racing\", \"🏇\"),\n            Map.entry(\"hospital\", \"🏥\"),\n            Map.entry(\"hot_pepper\", \"🌶\"),\n            Map.entry(\"hotel\", \"🏨\"),\n            Map.entry(\"hotsprings\", \"♨\"),\n            Map.entry(\"hourglass\", \"⌛\"),\n            Map.entry(\"hourglass_flowing_sand\", \"⏳\"),\n            Map.entry(\"house\", \"🏠\"),\n            Map.entry(\"house_abandoned\", \"🏚\"),\n            Map.entry(\"house_with_garden\", \"🏡\"),\n            Map.entry(\"hushed\", \"😯\"),\n            Map.entry(\"ice_cream\", \"🍨\"),\n            Map.entry(\"ice_skate\", \"⛸\"),\n            Map.entry(\"icecream\", \"🍦\"),\n            Map.entry(\"id\", \"🆔\"),\n            Map.entry(\"ideograph_advantage\", \"🉐\"),\n            Map.entry(\"imp\", \"👿\"),\n            Map.entry(\"inbox_tray\", \"📥\"),\n            Map.entry(\"incoming_envelope\", \"📨\"),\n            Map.entry(\"information_desk_person\", \"💁\"),\n            Map.entry(\"information_source\", \"ℹ\"),\n            Map.entry(\"innocent\", \"😇\"),\n            Map.entry(\"interrobang\", \"⁉\"),\n            Map.entry(\"iphone\", \"📱\"),\n            Map.entry(\"island\", \"🏝\"),\n            Map.entry(\"izakaya_lantern\", \"🏮\"),\n            Map.entry(\"jack_o_lantern\", \"🎃\"),\n            Map.entry(\"japan\", \"🗾\"),\n            Map.entry(\"japanese_castle\", \"🏯\"),\n            Map.entry(\"japanese_goblin\", \"👺\"),\n            Map.entry(\"japanese_ogre\", \"👹\"),\n            Map.entry(\"jeans\", \"👖\"),\n            Map.entry(\"joy\", \"😂\"),\n            Map.entry(\"joy_cat\", \"😹\"),\n            Map.entry(\"joystick\", \"🕹\"),\n            Map.entry(\"key\", \"🔑\"),\n            Map.entry(\"key2\", \"🗝\"),\n            Map.entry(\"keyboard\", \"⌨\"),\n            Map.entry(\"kimono\", \"👘\"),\n            Map.entry(\"kiss\", \"💋\"),\n            Map.entry(\"kiss_mm\", \"👨‍❤️‍💋‍👨\"),\n            Map.entry(\"kiss_ww\", \"👩‍❤️‍💋‍👩\"),\n            Map.entry(\"kissing\", \"😗\"),\n            Map.entry(\"kissing_cat\", \"😽\"),\n            Map.entry(\"kissing_closed_eyes\", \"😚\"),\n            Map.entry(\"kissing_heart\", \"😘\"),\n            Map.entry(\"kissing_smiling_eyes\", \"😙\"),\n            Map.entry(\"knife\", \"🔪\"),\n            Map.entry(\"koala\", \"🐨\"),\n            Map.entry(\"koko\", \"🈁\"),\n            Map.entry(\"label\", \"🏷\"),\n            Map.entry(\"large_blue_circle\", \"🔵\"),\n            Map.entry(\"large_blue_diamond\", \"🔷\"),\n            Map.entry(\"large_orange_diamond\", \"🔶\"),\n            Map.entry(\"last_quarter_moon\", \"🌗\"),\n            Map.entry(\"last_quarter_moon_with_face\", \"🌜\"),\n            Map.entry(\"laughing\", \"😆\"),\n            Map.entry(\"leaves\", \"🍃\"),\n            Map.entry(\"ledger\", \"📒\"),\n            Map.entry(\"left_luggage\", \"🛅\"),\n            Map.entry(\"left_right_arrow\", \"↔\"),\n            Map.entry(\"leftwards_arrow_with_hook\", \"↩\"),\n            Map.entry(\"lemon\", \"🍋\"),\n            Map.entry(\"leo\", \"♌\"),\n            Map.entry(\"leopard\", \"🐆\"),\n            Map.entry(\"level_slider\", \"🎚\"),\n            Map.entry(\"levitate\", \"🕴\"),\n            Map.entry(\"libra\", \"♎\"),\n            Map.entry(\"lifter\", \"🏋\"),\n            Map.entry(\"light_rail\", \"🚈\"),\n            Map.entry(\"link\", \"🔗\"),\n            Map.entry(\"lips\", \"👄\"),\n            Map.entry(\"lipstick\", \"💄\"),\n            Map.entry(\"lock\", \"🔒\"),\n            Map.entry(\"lock_with_ink_pen\", \"🔏\"),\n            Map.entry(\"lollipop\", \"🍭\"),\n            Map.entry(\"loop\", \"➿\"),\n            Map.entry(\"loud_sound\", \"🔊\"),\n            Map.entry(\"loudspeaker\", \"📢\"),\n            Map.entry(\"love_hotel\", \"🏩\"),\n            Map.entry(\"love_letter\", \"💌\"),\n            Map.entry(\"low_brightness\", \"🔅\"),\n            Map.entry(\"m\", \"Ⓜ\"),\n            Map.entry(\"mag\", \"🔍\"),\n            Map.entry(\"mag_right\", \"🔎\"),\n            Map.entry(\"mahjong\", \"🀄\"),\n            Map.entry(\"mailbox\", \"📫\"),\n            Map.entry(\"mailbox_closed\", \"📪\"),\n            Map.entry(\"mailbox_with_mail\", \"📬\"),\n            Map.entry(\"mailbox_with_no_mail\", \"📭\"),\n            Map.entry(\"man\", \"👨\"),\n            Map.entry(\"man_with_gua_pi_mao\", \"👲\"),\n            Map.entry(\"man_with_turban\", \"👳\"),\n            Map.entry(\"mans_shoe\", \"👞\"),\n            Map.entry(\"map\", \"🗺\"),\n            Map.entry(\"maple_leaf\", \"🍁\"),\n            Map.entry(\"mask\", \"😷\"),\n            Map.entry(\"massage\", \"💆\"),\n            Map.entry(\"meat_on_bone\", \"🍖\"),\n            Map.entry(\"medal\", \"🏅\"),\n            Map.entry(\"mega\", \"📣\"),\n            Map.entry(\"melon\", \"🍈\"),\n            Map.entry(\"mens\", \"🚹\"),\n            Map.entry(\"metro\", \"🚇\"),\n            Map.entry(\"microphone\", \"🎤\"),\n            Map.entry(\"microphone2\", \"🎙\"),\n            Map.entry(\"microscope\", \"🔬\"),\n            Map.entry(\"middle_finger\", \"🖕\"),\n            Map.entry(\"military_medal\", \"🎖\"),\n            Map.entry(\"milky_way\", \"🌌\"),\n            Map.entry(\"minibus\", \"🚐\"),\n            Map.entry(\"minidisc\", \"💽\"),\n            Map.entry(\"mobile_phone_off\", \"📴\"),\n            Map.entry(\"money_with_wings\", \"💸\"),\n            Map.entry(\"moneybag\", \"💰\"),\n            Map.entry(\"monkey\", \"🐒\"),\n            Map.entry(\"monkey_face\", \"🐵\"),\n            Map.entry(\"monorail\", \"🚝\"),\n            Map.entry(\"mortar_board\", \"🎓\"),\n            Map.entry(\"motorboat\", \"🛥\"),\n            Map.entry(\"motorcycle\", \"🏍\"),\n            Map.entry(\"motorway\", \"🛣\"),\n            Map.entry(\"mount_fuji\", \"🗻\"),\n            Map.entry(\"mountain\", \"⛰\"),\n            Map.entry(\"mountain_bicyclist\", \"🚵\"),\n            Map.entry(\"mountain_cableway\", \"🚠\"),\n            Map.entry(\"mountain_railway\", \"🚞\"),\n            Map.entry(\"mountain_snow\", \"🏔\"),\n            Map.entry(\"mouse\", \"🐭\"),\n            Map.entry(\"mouse2\", \"🐁\"),\n            Map.entry(\"mouse_three_button\", \"🖱\"),\n            Map.entry(\"movie_camera\", \"🎥\"),\n            Map.entry(\"moyai\", \"🗿\"),\n            Map.entry(\"muscle\", \"💪\"),\n            Map.entry(\"mushroom\", \"🍄\"),\n            Map.entry(\"musical_keyboard\", \"🎹\"),\n            Map.entry(\"musical_note\", \"🎵\"),\n            Map.entry(\"musical_score\", \"🎼\"),\n            Map.entry(\"mute\", \"🔇\"),\n            Map.entry(\"nail_care\", \"💅\"),\n            Map.entry(\"name_badge\", \"📛\"),\n            Map.entry(\"necktie\", \"👔\"),\n            Map.entry(\"negative_squared_cross_mark\", \"❎\"),\n            Map.entry(\"neutral_face\", \"😐\"),\n            Map.entry(\"new\", \"🆕\"),\n            Map.entry(\"new_moon\", \"🌑\"),\n            Map.entry(\"new_moon_with_face\", \"🌚\"),\n            Map.entry(\"newspaper\", \"📰\"),\n            Map.entry(\"newspaper2\", \"🗞\"),\n            Map.entry(\"ng\", \"🆖\"),\n            Map.entry(\"night_with_stars\", \"🌃\"),\n            Map.entry(\"nine\", \"9️⃣\"),\n            Map.entry(\"no_bell\", \"🔕\"),\n            Map.entry(\"no_bicycles\", \"🚳\"),\n            Map.entry(\"no_entry\", \"⛔\"),\n            Map.entry(\"no_entry_sign\", \"🚫\"),\n            Map.entry(\"no_good\", \"🙅\"),\n            Map.entry(\"no_mobile_phones\", \"📵\"),\n            Map.entry(\"no_mouth\", \"😶\"),\n            Map.entry(\"no_pedestrians\", \"🚷\"),\n            Map.entry(\"no_smoking\", \"🚭\"),\n            Map.entry(\"non-potable_water\", \"🚱\"),\n            Map.entry(\"nose\", \"👃\"),\n            Map.entry(\"notebook\", \"📓\"),\n            Map.entry(\"notebook_with_decorative_cover\", \"📔\"),\n            Map.entry(\"notepad_spiral\", \"🗒\"),\n            Map.entry(\"notes\", \"🎶\"),\n            Map.entry(\"nut_and_bolt\", \"🔩\"),\n            Map.entry(\"o\", \"⭕\"),\n            Map.entry(\"o2\", \"🅾\"),\n            Map.entry(\"ocean\", \"🌊\"),\n            Map.entry(\"octopus\", \"🐙\"),\n            Map.entry(\"oden\", \"🍢\"),\n            Map.entry(\"office\", \"🏢\"),\n            Map.entry(\"oil\", \"🛢\"),\n            Map.entry(\"ok\", \"🆗\"),\n            Map.entry(\"ok_hand\", \"👌\"),\n            Map.entry(\"ok_woman\", \"🙆\"),\n            Map.entry(\"older_man\", \"👴\"),\n            Map.entry(\"older_woman\", \"👵\"),\n            Map.entry(\"om_symbol\", \"🕉\"),\n            Map.entry(\"on\", \"🔛\"),\n            Map.entry(\"oncoming_automobile\", \"🚘\"),\n            Map.entry(\"oncoming_bus\", \"🚍\"),\n            Map.entry(\"oncoming_police_car\", \"🚔\"),\n            Map.entry(\"oncoming_taxi\", \"🚖\"),\n            Map.entry(\"one\", \"1️⃣\"),\n            Map.entry(\"open_file_folder\", \"📂\"),\n            Map.entry(\"open_hands\", \"👐\"),\n            Map.entry(\"open_mouth\", \"😮\"),\n            Map.entry(\"ophiuchus\", \"⛎\"),\n            Map.entry(\"orange_book\", \"📙\"),\n            Map.entry(\"orthodox_cross\", \"☦\"),\n            Map.entry(\"outbox_tray\", \"📤\"),\n            Map.entry(\"ox\", \"🐂\"),\n            Map.entry(\"package\", \"📦\"),\n            Map.entry(\"page_facing_up\", \"📄\"),\n            Map.entry(\"page_with_curl\", \"📃\"),\n            Map.entry(\"pager\", \"📟\"),\n            Map.entry(\"paintbrush\", \"🖌\"),\n            Map.entry(\"palm_tree\", \"🌴\"),\n            Map.entry(\"panda_face\", \"🐼\"),\n            Map.entry(\"paperclip\", \"📎\"),\n            Map.entry(\"paperclips\", \"🖇\"),\n            Map.entry(\"park\", \"🏞\"),\n            Map.entry(\"parking\", \"🅿\"),\n            Map.entry(\"part_alternation_mark\", \"〽\"),\n            Map.entry(\"partly_sunny\", \"⛅\"),\n            Map.entry(\"passport_control\", \"🛂\"),\n            Map.entry(\"pause_button\", \"⏸\"),\n            Map.entry(\"peace\", \"☮\"),\n            Map.entry(\"peach\", \"🍑\"),\n            Map.entry(\"pear\", \"🍐\"),\n            Map.entry(\"pen_ballpoint\", \"🖊\"),\n            Map.entry(\"pen_fountain\", \"🖋\"),\n            Map.entry(\"pencil\", \"📝\"),\n            Map.entry(\"pencil2\", \"✏\"),\n            Map.entry(\"penguin\", \"🐧\"),\n            Map.entry(\"pensive\", \"😔\"),\n            Map.entry(\"performing_arts\", \"🎭\"),\n            Map.entry(\"persevere\", \"😣\"),\n            Map.entry(\"person_frowning\", \"🙍\"),\n            Map.entry(\"person_with_blond_hair\", \"👱\"),\n            Map.entry(\"person_with_pouting_face\", \"🙎\"),\n            Map.entry(\"pick\", \"⛏\"),\n            Map.entry(\"pig\", \"🐷\"),\n            Map.entry(\"pig2\", \"🐖\"),\n            Map.entry(\"pig_nose\", \"🐽\"),\n            Map.entry(\"pill\", \"💊\"),\n            Map.entry(\"pineapple\", \"🍍\"),\n            Map.entry(\"pisces\", \"♓\"),\n            Map.entry(\"pizza\", \"🍕\"),\n            Map.entry(\"play_pause\", \"⏯\"),\n            Map.entry(\"point_down\", \"👇\"),\n            Map.entry(\"point_left\", \"👈\"),\n            Map.entry(\"point_right\", \"👉\"),\n            Map.entry(\"point_up\", \"☝\"),\n            Map.entry(\"point_up_2\", \"👆\"),\n            Map.entry(\"police_car\", \"🚓\"),\n            Map.entry(\"poodle\", \"🐩\"),\n            Map.entry(\"poop\", \"💩\"),\n            Map.entry(\"post_office\", \"🏣\"),\n            Map.entry(\"postal_horn\", \"📯\"),\n            Map.entry(\"postbox\", \"📮\"),\n            Map.entry(\"potable_water\", \"🚰\"),\n            Map.entry(\"pouch\", \"👝\"),\n            Map.entry(\"poultry_leg\", \"🍗\"),\n            Map.entry(\"pound\", \"💷\"),\n            Map.entry(\"pouting_cat\", \"😾\"),\n            Map.entry(\"pray\", \"🙏\"),\n            Map.entry(\"princess\", \"👸\"),\n            Map.entry(\"printer\", \"🖨\"),\n            Map.entry(\"projector\", \"📽\"),\n            Map.entry(\"punch\", \"👊\"),\n            Map.entry(\"purple_heart\", \"💜\"),\n            Map.entry(\"purse\", \"👛\"),\n            Map.entry(\"pushpin\", \"📌\"),\n            Map.entry(\"put_litter_in_its_place\", \"🚮\"),\n            Map.entry(\"question\", \"❓\"),\n            Map.entry(\"rabbit\", \"🐰\"),\n            Map.entry(\"rabbit2\", \"🐇\"),\n            Map.entry(\"race_car\", \"🏎\"),\n            Map.entry(\"racehorse\", \"🐎\"),\n            Map.entry(\"radio\", \"📻\"),\n            Map.entry(\"radio_button\", \"🔘\"),\n            Map.entry(\"radioactive\", \"☢\"),\n            Map.entry(\"rage\", \"😡\"),\n            Map.entry(\"railway_car\", \"🚃\"),\n            Map.entry(\"railway_track\", \"🛤\"),\n            Map.entry(\"rainbow\", \"🌈\"),\n            Map.entry(\"raised_hand\", \"✋\"),\n            Map.entry(\"raised_hands\", \"🙌\"),\n            Map.entry(\"raising_hand\", \"🙋\"),\n            Map.entry(\"ram\", \"🐏\"),\n            Map.entry(\"ramen\", \"🍜\"),\n            Map.entry(\"rat\", \"🐀\"),\n            Map.entry(\"record_button\", \"⏺\"),\n            Map.entry(\"recycle\", \"♻\"),\n            Map.entry(\"red_car\", \"🚗\"),\n            Map.entry(\"red_circle\", \"🔴\"),\n            Map.entry(\"registered\", \"®\"),\n            Map.entry(\"relaxed\", \"☺\"),\n            Map.entry(\"relieved\", \"😌\"),\n            Map.entry(\"reminder_ribbon\", \"🎗\"),\n            Map.entry(\"repeat\", \"🔁\"),\n            Map.entry(\"repeat_one\", \"🔂\"),\n            Map.entry(\"restroom\", \"🚻\"),\n            Map.entry(\"revolving_hearts\", \"💞\"),\n            Map.entry(\"rewind\", \"⏪\"),\n            Map.entry(\"ribbon\", \"🎀\"),\n            Map.entry(\"rice\", \"🍚\"),\n            Map.entry(\"rice_ball\", \"🍙\"),\n            Map.entry(\"rice_cracker\", \"🍘\"),\n            Map.entry(\"rice_scene\", \"🎑\"),\n            Map.entry(\"ring\", \"💍\"),\n            Map.entry(\"rocket\", \"🚀\"),\n            Map.entry(\"roller_coaster\", \"🎢\"),\n            Map.entry(\"rooster\", \"🐓\"),\n            Map.entry(\"rose\", \"🌹\"),\n            Map.entry(\"rosette\", \"🏵\"),\n            Map.entry(\"rotating_light\", \"🚨\"),\n            Map.entry(\"round_pushpin\", \"📍\"),\n            Map.entry(\"rowboat\", \"🚣\"),\n            Map.entry(\"rugby_football\", \"🏉\"),\n            Map.entry(\"runner\", \"🏃\"),\n            Map.entry(\"running_shirt_with_sash\", \"🎽\"),\n            Map.entry(\"sa\", \"🈂\"),\n            Map.entry(\"sagittarius\", \"♐\"),\n            Map.entry(\"sailboat\", \"⛵\"),\n            Map.entry(\"sake\", \"🍶\"),\n            Map.entry(\"sandal\", \"👡\"),\n            Map.entry(\"santa\", \"🎅\"),\n            Map.entry(\"satellite\", \"📡\"),\n            Map.entry(\"satellite_orbital\", \"🛰\"),\n            Map.entry(\"saxophone\", \"🎷\"),\n            Map.entry(\"scales\", \"⚖\"),\n            Map.entry(\"school\", \"🏫\"),\n            Map.entry(\"school_satchel\", \"🎒\"),\n            Map.entry(\"scissors\", \"✂\"),\n            Map.entry(\"scorpius\", \"♏\"),\n            Map.entry(\"scream\", \"😱\"),\n            Map.entry(\"scream_cat\", \"🙀\"),\n            Map.entry(\"scroll\", \"📜\"),\n            Map.entry(\"seat\", \"💺\"),\n            Map.entry(\"secret\", \"㊙\"),\n            Map.entry(\"see_no_evil\", \"🙈\"),\n            Map.entry(\"seedling\", \"🌱\"),\n            Map.entry(\"seven\", \"7️⃣\"),\n            Map.entry(\"shamrock\", \"☘\"),\n            Map.entry(\"shaved_ice\", \"🍧\"),\n            Map.entry(\"sheep\", \"🐑\"),\n            Map.entry(\"shell\", \"🐚\"),\n            Map.entry(\"shield\", \"🛡\"),\n            Map.entry(\"shinto_shrine\", \"⛩\"),\n            Map.entry(\"ship\", \"🚢\"),\n            Map.entry(\"shirt\", \"👕\"),\n            Map.entry(\"shopping_bags\", \"🛍\"),\n            Map.entry(\"shower\", \"🚿\"),\n            Map.entry(\"signal_strength\", \"📶\"),\n            Map.entry(\"six\", \"6️⃣\"),\n            Map.entry(\"six_pointed_star\", \"🔯\"),\n            Map.entry(\"ski\", \"🎿\"),\n            Map.entry(\"skier\", \"⛷\"),\n            Map.entry(\"skull\", \"💀\"),\n            Map.entry(\"skull_crossbones\", \"☠\"),\n            Map.entry(\"sleeping\", \"😴\"),\n            Map.entry(\"sleeping_accommodation\", \"🛌\"),\n            Map.entry(\"sleepy\", \"😪\"),\n            Map.entry(\"slight_frown\", \"🙁\"),\n            Map.entry(\"slight_smile\", \"🙂\"),\n            Map.entry(\"slot_machine\", \"🎰\"),\n            Map.entry(\"small_blue_diamond\", \"🔹\"),\n            Map.entry(\"small_orange_diamond\", \"🔸\"),\n            Map.entry(\"small_red_triangle\", \"🔺\"),\n            Map.entry(\"small_red_triangle_down\", \"🔻\"),\n            Map.entry(\"smile\", \"😄\"),\n            Map.entry(\"smile_cat\", \"😸\"),\n            Map.entry(\"smiley\", \"😃\"),\n            Map.entry(\"smiley_cat\", \"😺\"),\n            Map.entry(\"smiling_imp\", \"😈\"),\n            Map.entry(\"smirk\", \"😏\"),\n            Map.entry(\"smirk_cat\", \"😼\"),\n            Map.entry(\"smoking\", \"🚬\"),\n            Map.entry(\"snail\", \"🐌\"),\n            Map.entry(\"snake\", \"🐍\"),\n            Map.entry(\"snowboarder\", \"🏂\"),\n            Map.entry(\"snowflake\", \"❄\"),\n            Map.entry(\"snowman\", \"⛄\"),\n            Map.entry(\"snowman2\", \"☃\"),\n            Map.entry(\"sob\", \"😭\"),\n            Map.entry(\"soccer\", \"⚽\"),\n            Map.entry(\"soon\", \"🔜\"),\n            Map.entry(\"sos\", \"🆘\"),\n            Map.entry(\"sound\", \"🔉\"),\n            Map.entry(\"space_invader\", \"👾\"),\n            Map.entry(\"spades\", \"♠\"),\n            Map.entry(\"spaghetti\", \"🍝\"),\n            Map.entry(\"sparkle\", \"❇\"),\n            Map.entry(\"sparkler\", \"🎇\"),\n            Map.entry(\"sparkles\", \"✨\"),\n            Map.entry(\"sparkling_heart\", \"💖\"),\n            Map.entry(\"speak_no_evil\", \"🙊\"),\n            Map.entry(\"speaker\", \"🔈\"),\n            Map.entry(\"speaking_head\", \"🗣\"),\n            Map.entry(\"speech_balloon\", \"💬\"),\n            Map.entry(\"speech_left\", \"🗨\"),\n            Map.entry(\"speedboat\", \"🚤\"),\n            Map.entry(\"spider\", \"🕷\"),\n            Map.entry(\"spider_web\", \"🕸\"),\n            Map.entry(\"spy\", \"🕵\"),\n            Map.entry(\"stadium\", \"🏟\"),\n            Map.entry(\"star\", \"⭐\"),\n            Map.entry(\"star2\", \"🌟\"),\n            Map.entry(\"star_and_crescent\", \"☪\"),\n            Map.entry(\"star_of_david\", \"✡\"),\n            Map.entry(\"stars\", \"🌠\"),\n            Map.entry(\"station\", \"🚉\"),\n            Map.entry(\"statue_of_liberty\", \"🗽\"),\n            Map.entry(\"steam_locomotive\", \"🚂\"),\n            Map.entry(\"stew\", \"🍲\"),\n            Map.entry(\"stop_button\", \"⏹\"),\n            Map.entry(\"stopwatch\", \"⏱\"),\n            Map.entry(\"straight_ruler\", \"📏\"),\n            Map.entry(\"strawberry\", \"🍓\"),\n            Map.entry(\"stuck_out_tongue\", \"😛\"),\n            Map.entry(\"stuck_out_tongue_closed_eyes\", \"😝\"),\n            Map.entry(\"stuck_out_tongue_winking_eye\", \"😜\"),\n            Map.entry(\"sun_with_face\", \"🌞\"),\n            Map.entry(\"sunflower\", \"🌻\"),\n            Map.entry(\"sunglasses\", \"😎\"),\n            Map.entry(\"sunny\", \"☀\"),\n            Map.entry(\"sunrise\", \"🌅\"),\n            Map.entry(\"sunrise_over_mountains\", \"🌄\"),\n            Map.entry(\"surfer\", \"🏄\"),\n            Map.entry(\"sushi\", \"🍣\"),\n            Map.entry(\"suspension_railway\", \"🚟\"),\n            Map.entry(\"sweat\", \"😓\"),\n            Map.entry(\"sweat_drops\", \"💦\"),\n            Map.entry(\"sweat_smile\", \"😅\"),\n            Map.entry(\"sweet_potato\", \"🍠\"),\n            Map.entry(\"swimmer\", \"🏊\"),\n            Map.entry(\"symbols\", \"🔣\"),\n            Map.entry(\"syringe\", \"💉\"),\n            Map.entry(\"tada\", \"🎉\"),\n            Map.entry(\"tanabata_tree\", \"🎋\"),\n            Map.entry(\"tangerine\", \"🍊\"),\n            Map.entry(\"taurus\", \"♉\"),\n            Map.entry(\"taxi\", \"🚕\"),\n            Map.entry(\"tea\", \"🍵\"),\n            Map.entry(\"telephone\", \"☎\"),\n            Map.entry(\"telephone_receiver\", \"📞\"),\n            Map.entry(\"telescope\", \"🔭\"),\n            Map.entry(\"ten\", \"🔟\"),\n            Map.entry(\"tennis\", \"🎾\"),\n            Map.entry(\"tent\", \"⛺\"),\n            Map.entry(\"thermometer\", \"🌡\"),\n            Map.entry(\"thought_balloon\", \"💭\"),\n            Map.entry(\"three\", \"3️⃣\"),\n            Map.entry(\"thumbsdown\", \"👎\"),\n            Map.entry(\"thumbsup\", \"👍\"),\n            Map.entry(\"thunder_cloud_rain\", \"⛈\"),\n            Map.entry(\"ticket\", \"🎫\"),\n            Map.entry(\"tickets\", \"🎟\"),\n            Map.entry(\"tiger\", \"🐯\"),\n            Map.entry(\"tiger2\", \"🐅\"),\n            Map.entry(\"timer\", \"⏲\"),\n            Map.entry(\"tired_face\", \"😫\"),\n            Map.entry(\"tm\", \"™\"),\n            Map.entry(\"toilet\", \"🚽\"),\n            Map.entry(\"tokyo_tower\", \"🗼\"),\n            Map.entry(\"tomato\", \"🍅\"),\n            Map.entry(\"tongue\", \"👅\"),\n            Map.entry(\"tools\", \"🛠\"),\n            Map.entry(\"top\", \"🔝\"),\n            Map.entry(\"tophat\", \"🎩\"),\n            Map.entry(\"track_next\", \"⏭\"),\n            Map.entry(\"track_previous\", \"⏮\"),\n            Map.entry(\"trackball\", \"🖲\"),\n            Map.entry(\"tractor\", \"🚜\"),\n            Map.entry(\"traffic_light\", \"🚥\"),\n            Map.entry(\"train\", \"🚋\"),\n            Map.entry(\"train2\", \"🚆\"),\n            Map.entry(\"tram\", \"🚊\"),\n            Map.entry(\"triangular_flag_on_post\", \"🚩\"),\n            Map.entry(\"triangular_ruler\", \"📐\"),\n            Map.entry(\"trident\", \"🔱\"),\n            Map.entry(\"triumph\", \"😤\"),\n            Map.entry(\"trolleybus\", \"🚎\"),\n            Map.entry(\"trophy\", \"🏆\"),\n            Map.entry(\"tropical_drink\", \"🍹\"),\n            Map.entry(\"tropical_fish\", \"🐠\"),\n            Map.entry(\"truck\", \"🚚\"),\n            Map.entry(\"trumpet\", \"🎺\"),\n            Map.entry(\"tulip\", \"🌷\"),\n            Map.entry(\"turtle\", \"🐢\"),\n            Map.entry(\"tv\", \"📺\"),\n            Map.entry(\"twisted_rightwards_arrows\", \"🔀\"),\n            Map.entry(\"two\", \"2️⃣\"),\n            Map.entry(\"two_hearts\", \"💕\"),\n            Map.entry(\"two_men_holding_hands\", \"👬\"),\n            Map.entry(\"two_women_holding_hands\", \"👭\"),\n            Map.entry(\"u5272\", \"🈹\"),\n            Map.entry(\"u5408\", \"🈴\"),\n            Map.entry(\"u55b6\", \"🈺\"),\n            Map.entry(\"u6307\", \"🈯\"),\n            Map.entry(\"u6708\", \"🈷\"),\n            Map.entry(\"u6709\", \"🈶\"),\n            Map.entry(\"u6e80\", \"🈵\"),\n            Map.entry(\"u7121\", \"🈚\"),\n            Map.entry(\"u7533\", \"🈸\"),\n            Map.entry(\"u7981\", \"🈲\"),\n            Map.entry(\"u7a7a\", \"🈳\"),\n            Map.entry(\"umbrella\", \"☔\"),\n            Map.entry(\"umbrella2\", \"☂\"),\n            Map.entry(\"unamused\", \"😒\"),\n            Map.entry(\"underage\", \"🔞\"),\n            Map.entry(\"unlock\", \"🔓\"),\n            Map.entry(\"up\", \"🆙\"),\n            Map.entry(\"urn\", \"⚱\"),\n            Map.entry(\"v\", \"✌\"),\n            Map.entry(\"vertical_traffic_light\", \"🚦\"),\n            Map.entry(\"vhs\", \"📼\"),\n            Map.entry(\"vibration_mode\", \"📳\"),\n            Map.entry(\"video_camera\", \"📹\"),\n            Map.entry(\"video_game\", \"🎮\"),\n            Map.entry(\"violin\", \"🎻\"),\n            Map.entry(\"virgo\", \"♍\"),\n            Map.entry(\"volcano\", \"🌋\"),\n            Map.entry(\"vs\", \"🆚\"),\n            Map.entry(\"vulcan\", \"🖖\"),\n            Map.entry(\"walking\", \"🚶\"),\n            Map.entry(\"waning_crescent_moon\", \"🌘\"),\n            Map.entry(\"waning_gibbous_moon\", \"🌖\"),\n            Map.entry(\"warning\", \"⚠\"),\n            Map.entry(\"wastebasket\", \"🗑\"),\n            Map.entry(\"watch\", \"⌚\"),\n            Map.entry(\"water_buffalo\", \"🐃\"),\n            Map.entry(\"watermelon\", \"🍉\"),\n            Map.entry(\"wave\", \"👋\"),\n            Map.entry(\"wavy_dash\", \"〰\"),\n            Map.entry(\"waxing_crescent_moon\", \"🌒\"),\n            Map.entry(\"waxing_gibbous_moon\", \"🌔\"),\n            Map.entry(\"wc\", \"🚾\"),\n            Map.entry(\"weary\", \"😩\"),\n            Map.entry(\"wedding\", \"💒\"),\n            Map.entry(\"whale\", \"🐳\"),\n            Map.entry(\"whale2\", \"🐋\"),\n            Map.entry(\"wheel_of_dharma\", \"☸\"),\n            Map.entry(\"wheelchair\", \"♿\"),\n            Map.entry(\"white_check_mark\", \"✅\"),\n            Map.entry(\"white_circle\", \"⚪\"),\n            Map.entry(\"white_flower\", \"💮\"),\n            Map.entry(\"white_large_square\", \"⬜\"),\n            Map.entry(\"white_medium_small_square\", \"◽\"),\n            Map.entry(\"white_medium_square\", \"◻\"),\n            Map.entry(\"white_small_square\", \"▫\"),\n            Map.entry(\"white_square_button\", \"🔳\"),\n            Map.entry(\"white_sun_cloud\", \"🌥\"),\n            Map.entry(\"white_sun_rain_cloud\", \"🌦\"),\n            Map.entry(\"white_sun_small_cloud\", \"🌤\"),\n            Map.entry(\"wind_blowing_face\", \"🌬\"),\n            Map.entry(\"wind_chime\", \"🎐\"),\n            Map.entry(\"wine_glass\", \"🍷\"),\n            Map.entry(\"wink\", \"😉\"),\n            Map.entry(\"wolf\", \"🐺\"),\n            Map.entry(\"woman\", \"👩\"),\n            Map.entry(\"womans_clothes\", \"👚\"),\n            Map.entry(\"womans_hat\", \"👒\"),\n            Map.entry(\"womens\", \"🚺\"),\n            Map.entry(\"worried\", \"😟\"),\n            Map.entry(\"wrench\", \"🔧\"),\n            Map.entry(\"writing_hand\", \"✍\"),\n            Map.entry(\"x\", \"❌\"),\n            Map.entry(\"yellow_heart\", \"💛\"),\n            Map.entry(\"yen\", \"💴\"),\n            Map.entry(\"yin_yang\", \"☯\"),\n            Map.entry(\"yum\", \"😋\"),\n            Map.entry(\"zap\", \"⚡\"),\n            Map.entry(\"zero\", \"0️⃣\"),\n            Map.entry(\"zzz\", \"💤\")\n    );\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/HostUserToEmailAuthor.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.host.HostUser;\n\n@FunctionalInterface\ninterface HostUserToEmailAuthor {\n    EmailAddress author(HostUser user);\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/HostUserToRole.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.host.HostUser;\n\n@FunctionalInterface\ninterface HostUserToRole {\n    String role(HostUser user);\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/HostUserToUsername.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.host.HostUser;\n\n@FunctionalInterface\ninterface HostUserToUsername {\n    String username(HostUser user);\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/LabelsUpdaterWorkItem.java",
    "content": "/*\n * Copyright (c) 2021, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.issuetracker.Label;\n\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.logging.Logger;\n\n/**\n * This WorkItem runs once when the bots starts up to update the repository\n * with all mailing list labels configured for it.\n */\npublic class LabelsUpdaterWorkItem implements WorkItem {\n    private static final Logger log = Logger.getLogger(LabelsUpdaterWorkItem.class.getName());\n\n    private final MailingListBridgeBot bot;\n\n    public LabelsUpdaterWorkItem(MailingListBridgeBot bot) {\n        this.bot = bot;\n    }\n\n    public MailingListBridgeBot bot() {\n        return bot;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof LabelsUpdaterWorkItem otherItem)) {\n            return true;\n        }\n        if (!bot.equals(otherItem.bot)) {\n            return true;\n        }\n        return false;\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        if (bot.labelsUpdated()) {\n            return List.of();\n        }\n\n        var existingLabelsMap = new HashMap<String, Label>();\n        bot.codeRepo().labels().forEach(l -> existingLabelsMap.put(l.name(), l));\n\n        var configuredLabels = bot.lists().stream()\n                .flatMap(configuration -> configuration.labels().stream()\n                        .map(labelName -> new Label(labelName, configuration.list().address())))\n                .toList();\n\n        for (Label configuredLabel : configuredLabels) {\n            var existingLabel = existingLabelsMap.get(configuredLabel.name());\n            if (existingLabel == null) {\n                log.info(\"Adding label: \" + configuredLabel.name() + \" to repo: \" + bot.codeRepo().name());\n                bot.codeRepo().addLabel(configuredLabel);\n            } else if (!existingLabel.description().equals(configuredLabel.description())) {\n                log.info(\"Updating label: \" + configuredLabel.name() + \" with description: \"\n                        + configuredLabel.description() + \" for repo: \" + bot.codeRepo().name());\n                bot.codeRepo().updateLabel(configuredLabel);\n            }\n        }\n\n        log.fine(\"Done updating labels for: \" + bot.codeRepo().name());\n        bot.setLabelsUpdated(true);\n        return List.of();\n    }\n\n    @Override\n    public String botName() {\n        return MailingListBridgeBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return \"labels-updater\";\n    }\n\n    @Override\n    public String toString() {\n        return \"LabelsUpdaterWorkItem@\" + bot.codeRepo().name();\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/MailingListArchiveReaderBot.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.email.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.mailinglist.*;\n\nimport java.time.*;\nimport java.util.*;\nimport java.util.concurrent.*;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class MailingListArchiveReaderBot implements Bot {\n    private final MailingListReader mailingListReader;\n    private final HostedRepository repository;\n    private final Map<EmailAddress, String> parsedConversations = new HashMap<>();\n    private final Map<EmailAddress, PullRequest> resolvedPullRequests = new HashMap<>();\n    private final Set<EmailAddress> parsedEmailIds = new HashSet<>();\n    private final Queue<CommentPosterWorkItem> commentQueue = new ConcurrentLinkedQueue<>();\n    private static final Pattern PULL_REQUEST_LINK_PATTERN = Pattern.compile(\"^(?:PR: |Pull request:\\\\R)(.*?)$\", Pattern.MULTILINE);\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.mlbridge\");\n\n    MailingListArchiveReaderBot(MailingListReader mailingListReader, HostedRepository repository) {\n        this.mailingListReader = mailingListReader;\n        this.repository = repository;\n    }\n\n    public HostedRepository repository() {\n        return repository;\n    }\n\n    private synchronized void invalidate(List<Email> messages) {\n        messages.forEach(m -> parsedEmailIds.remove(m.id()));\n    }\n\n    synchronized void inspect(Conversation conversation) {\n        // Is this a new conversation?\n        var first = conversation.first();\n        if (!parsedConversations.containsKey(first.id())) {\n            // This conversation has already been parsed without finding any matching PR\n            if (parsedEmailIds.contains(first.id())) {\n                return;\n            }\n\n            parsedEmailIds.add(first.id());\n\n            // Not an RFR - cannot match a PR\n            if (!first.subject().contains(\"RFR: \")) {\n                return;\n            }\n\n            // Look for a pull request link\n            var matcher = PULL_REQUEST_LINK_PATTERN.matcher(first.body());\n            if (!matcher.find()) {\n                log.fine(\"RFR email without valid pull request link: \" + first.date() + \" - \" + first.subject());\n                return;\n            }\n\n            // Valid looking pull request link found!\n            parsedConversations.put(first.id(), matcher.group(1));\n            parsedEmailIds.remove(first.id());\n        }\n\n        // Are there any new messages? We avoid looking further back than 14 days. If the bridge has been down\n        // for more than 14 days, this may have to be temporarily increased.\n        var newMessages = conversation.allMessages().stream()\n                                      .filter(email -> email.date().isAfter(ZonedDateTime.now().minus(Duration.ofDays(14))))\n                                      .filter(email -> !parsedEmailIds.contains(email.id()))\n                                      .collect(Collectors.toList());\n        if (newMessages.isEmpty()) {\n            return;\n        }\n\n        for (var newMessage : newMessages) {\n            parsedEmailIds.add(newMessage.id());\n        }\n\n        var pr = resolvedPullRequests.get(first.id());\n        if (pr == null) {\n            var prLink = parsedConversations.get(first.id());\n            if (prLink.equals(\"invalid\")) {\n                return;\n            }\n            var foundPr = repository.parsePullRequestUrl(prLink);\n            if (foundPr.isEmpty()) {\n                log.info(\"PR link that can't be matched to an actual PR: \" + prLink);\n                parsedConversations.put(first.id(), \"invalid\");\n                return;\n            }\n            pr = foundPr.get();\n            resolvedPullRequests.put(first.id(), pr);\n        }\n        var bridgeIdPattern = Pattern.compile(\"^[^.]+\\\\.[^.]+@\" + pr.repository().authenticatedUrl().getHost() + \"$\");\n\n        // Filter out already bridged comments\n        var bridgeCandidates = newMessages.stream()\n                                          .filter(email -> !bridgeIdPattern.matcher(email.id().address()).matches())\n                                          .collect(Collectors.toList());\n        if (bridgeCandidates.isEmpty()) {\n            return;\n        }\n\n        log.info(\"Found \" + bridgeCandidates.size() + \" candidates for comments\");\n        var workItem = new CommentPosterWorkItem(pr, bridgeCandidates, e -> invalidate(bridgeCandidates));\n        commentQueue.add(workItem);\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        var readerItems = new ArchiveReaderWorkItem(this, mailingListReader);\n        var ret = new ArrayList<WorkItem>();\n        ret.add(readerItems);\n\n        // Check if there are any potential new comments to post\n        var item = commentQueue.poll();\n        while (item != null) {\n            ret.add(item);\n            item = commentQueue.poll();\n        }\n\n        return ret;\n    }\n\n    public MailingListReader mailingListReader() {\n        return mailingListReader;\n    }\n\n    @Override\n    public String name() {\n        return MailingListBridgeBotFactory.NAME;\n    }\n\n    @Override\n    public String toString() {\n        return \"MailingListArchiveReaderBot@\" + repository.name();\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/MailingListBridgeBot.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.forge.*;\n\nimport java.net.URI;\nimport java.nio.file.Path;\nimport java.time.*;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\nimport org.openjdk.skara.mailinglist.MailingListServer;\n\npublic class MailingListBridgeBot implements Bot {\n    private final EmailAddress emailAddress;\n    private final HostedRepository codeRepo;\n    private final HostedRepository archiveRepo;\n    private final String archiveRef;\n    private final HostedRepository censusRepo;\n    private final String censusRef;\n    private final List<MailingListConfiguration> lists;\n    private final Set<String> ignoredUsers;\n    private final Set<Pattern> ignoredComments;\n    private final WebrevStorage webrevStorage;\n    private final Set<String> readyLabels;\n    private final Map<String, Pattern> readyComments;\n    private final Map<String, String> headers;\n    private final URI issueTracker;\n    private final Duration cooldown;\n    private final boolean repoInSubject;\n    private final Pattern branchInSubject;\n    private final Path seedStorage;\n    private final PullRequestPoller poller;\n    private final MailingListServer mailingListServer;\n\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.mlbridge\");\n\n    private volatile boolean labelsUpdated = false;\n\n    MailingListBridgeBot(EmailAddress from, HostedRepository repo, HostedRepository archive, String archiveRef,\n                         HostedRepository censusRepo, String censusRef, List<MailingListConfiguration> lists,\n                         Set<String> ignoredUsers, Set<Pattern> ignoredComments,\n                         HostedRepository webrevStorageHTMLRepository, HostedRepository webrevStorageJSONRepository,\n                         String webrevStorageRef, Path webrevStorageBase, URI webrevStorageBaseUri,\n                         boolean webrevGenerateHTML, boolean webrevGenerateJSON, Set<String> readyLabels,\n                         Map<String, Pattern> readyComments, URI issueTracker, Map<String, String> headers,\n                         Duration cooldown, boolean repoInSubject, Pattern branchInSubject,\n                         Path seedStorage, MailingListServer mailingListServer) {\n        emailAddress = from;\n        codeRepo = repo;\n        archiveRepo = archive;\n        this.archiveRef = archiveRef;\n        this.censusRepo = censusRepo;\n        this.censusRef = censusRef;\n        this.lists = lists;\n        this.ignoredUsers = ignoredUsers;\n        this.ignoredComments = ignoredComments;\n        this.readyLabels = readyLabels;\n        this.readyComments = readyComments;\n        this.headers = headers;\n        this.issueTracker = issueTracker;\n        this.cooldown = cooldown;\n        this.repoInSubject = repoInSubject;\n        this.branchInSubject = branchInSubject;\n        this.seedStorage = seedStorage;\n        this.mailingListServer = mailingListServer;\n\n        webrevStorage = new WebrevStorage(webrevStorageHTMLRepository, webrevStorageJSONRepository, webrevStorageRef,\n                                          webrevStorageBase, webrevStorageBaseUri, from,\n                                          webrevGenerateHTML, webrevGenerateJSON);\n        poller = new PullRequestPoller(codeRepo, true);\n    }\n\n    static MailingListBridgeBotBuilder newBuilder() {\n        return new MailingListBridgeBotBuilder();\n    }\n\n    HostedRepository codeRepo() {\n        return codeRepo;\n    }\n\n    HostedRepository archiveRepo() {\n        return archiveRepo;\n    }\n\n    String archiveRef() {\n        return archiveRef;\n    }\n\n    HostedRepository censusRepo() {\n        return censusRepo;\n    }\n\n    String censusRef() {\n        return censusRef;\n    }\n\n    EmailAddress emailAddress() {\n        return emailAddress;\n    }\n\n    List<MailingListConfiguration> lists() {\n        return lists;\n    }\n\n    Duration cooldown() {\n        return cooldown;\n    }\n\n    Set<String> ignoredUsers() {\n        return ignoredUsers;\n    }\n\n    Set<Pattern> ignoredComments() {\n        return ignoredComments;\n    }\n\n    WebrevStorage webrevStorage() {\n        return webrevStorage;\n    }\n\n    Set<String> readyLabels() {\n        return readyLabels;\n    }\n\n    Map<String, Pattern> readyComments() {\n        return readyComments;\n    }\n\n    Map<String, String> headers() {\n        return headers;\n    }\n\n    URI issueTracker() {\n        return issueTracker;\n    }\n\n    boolean repoInSubject() {\n        return repoInSubject;\n    }\n\n    Pattern branchInSubject() {\n        return branchInSubject;\n    }\n\n    Optional<Path> seedStorage() {\n        return Optional.ofNullable(seedStorage);\n    }\n\n    public boolean labelsUpdated() {\n        return labelsUpdated;\n    }\n\n    public void setLabelsUpdated(boolean labelsUpdated) {\n        this.labelsUpdated = labelsUpdated;\n    }\n\n    public MailingListServer mailingListServer() {\n        return mailingListServer;\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        List<WorkItem> ret = new LinkedList<>();\n\n        if (!labelsUpdated) {\n            ret.add(new LabelsUpdaterWorkItem(this));\n        }\n\n        List<PullRequest> prs = poller.updatedPullRequests();\n        prs.stream()\n                .map(pr -> new ArchiveWorkItem(pr, this,\n                        e -> poller.retryPullRequest(pr),\n                        r -> poller.quarantinePullRequest(pr, r)))\n                .forEach(ret::add);\n        poller.lastBatchHandled();\n\n        return ret;\n    }\n\n    @Override\n    public String name() {\n        return MailingListBridgeBotFactory.NAME;\n    }\n\n    @Override\n    public String toString() {\n        return \"MailingListBridgeBot@\" + codeRepo.name();\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/MailingListBridgeBotBuilder.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.mailinglist.MailingListServer;\nimport org.openjdk.skara.vcs.Branch;\nimport org.openjdk.skara.vcs.VCS;\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.forge.HostedRepository;\n\nimport java.net.URI;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\npublic class MailingListBridgeBotBuilder {\n    private EmailAddress from;\n    private HostedRepository repo;\n    private HostedRepository archive;\n    private String archiveRef = Branch.defaultFor(VCS.GIT).name();\n    private HostedRepository censusRepo;\n    private String censusRef = Branch.defaultFor(VCS.GIT).name();\n    private List<MailingListConfiguration> lists;\n    private Set<String> ignoredUsers = Set.of();\n    private Set<Pattern> ignoredComments = Set.of();\n    private HostedRepository webrevStorageHTMLRepository;\n    private HostedRepository webrevStorageJSONRepository;\n    private String webrevStorageRef;\n    private Path webrevStorageBase;\n    private URI webrevStorageBaseUri;\n    private boolean webrevGenerateHTML = true;\n    private boolean webrevGenerateJSON = false;\n    private Set<String> readyLabels = Set.of();\n    private Map<String, Pattern> readyComments = Map.of();\n    private URI issueTracker;\n    private Map<String, String> headers = Map.of();\n    private Duration cooldown = Duration.ZERO;\n    private boolean repoInSubject = false;\n    private Pattern branchInSubject = Pattern.compile(\"a^\"); // Does not match anything\n    private Path seedStorage = null;\n    private MailingListServer mailingListServer;\n\n    MailingListBridgeBotBuilder() {\n    }\n\n    public MailingListBridgeBotBuilder from(EmailAddress from) {\n        this.from = from;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder repo(HostedRepository repo) {\n        this.repo = repo;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder archive(HostedRepository archive) {\n        this.archive = archive;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder archiveRef(String archiveRef) {\n        this.archiveRef = archiveRef;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder censusRepo(HostedRepository censusRepo) {\n        this.censusRepo = censusRepo;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder censusRef(String censusRef) {\n        this.censusRef = censusRef;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder lists(List<MailingListConfiguration> lists) {\n        this.lists = lists;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder ignoredUsers(Set<String> ignoredUsers) {\n        this.ignoredUsers = ignoredUsers;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder ignoredComments(Set<Pattern> ignoredComments) {\n        this.ignoredComments = ignoredComments;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder webrevStorageHTMLRepository(HostedRepository webrevStorageHTMLRepository) {\n        this.webrevStorageHTMLRepository = webrevStorageHTMLRepository;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder webrevStorageJSONRepository(HostedRepository webrevStorageJSONRepository) {\n        this.webrevStorageJSONRepository = webrevStorageJSONRepository;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder webrevStorageRef(String webrevStorageRef) {\n        this.webrevStorageRef = webrevStorageRef;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder webrevStorageBase(Path webrevStorageBase) {\n        this.webrevStorageBase = webrevStorageBase;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder webrevStorageBaseUri(URI webrevStorageBaseUri) {\n        this.webrevStorageBaseUri = webrevStorageBaseUri;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder webrevGenerateHTML(boolean webrevGenerateHTML) {\n        this.webrevGenerateHTML = webrevGenerateHTML;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder webrevGenerateJSON(boolean webrevGenerateJSON) {\n        this.webrevGenerateJSON = webrevGenerateJSON;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder readyLabels(Set<String> readyLabels) {\n        this.readyLabels = readyLabels;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder readyComments(Map<String, Pattern> readyComments) {\n        this.readyComments = readyComments;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder issueTracker(URI issueTracker) {\n        this.issueTracker = issueTracker;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder headers(Map<String, String> headers) {\n        this.headers = headers;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder cooldown(Duration cooldown) {\n        this.cooldown = cooldown;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder repoInSubject(boolean repoInSubject) {\n        this.repoInSubject = repoInSubject;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder branchInSubject(Pattern branchInSubject) {\n        this.branchInSubject = branchInSubject;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder seedStorage(Path seedStorage) {\n        this.seedStorage = seedStorage;\n        return this;\n    }\n\n    public MailingListBridgeBotBuilder mailingListServer(MailingListServer mailingListServer) {\n        this.mailingListServer = mailingListServer;\n        return this;\n    }\n\n    public MailingListBridgeBot build() {\n        return new MailingListBridgeBot(from, repo, archive, archiveRef, censusRepo, censusRef, lists,\n                                        ignoredUsers, ignoredComments,\n                                        webrevStorageHTMLRepository, webrevStorageJSONRepository, webrevStorageRef,\n                                        webrevStorageBase, webrevStorageBaseUri, webrevGenerateHTML, webrevGenerateJSON,\n                                        readyLabels, readyComments, issueTracker, headers,\n                                        cooldown, repoInSubject, branchInSubject, seedStorage, mailingListServer);\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/MailingListBridgeBotFactory.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport java.net.URI;\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.mailinglist.MailingListReader;\nimport org.openjdk.skara.mailinglist.MailingListServer;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.mailinglist.MailingListServerFactory;\n\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class MailingListBridgeBotFactory implements BotFactory {\n    static final String NAME = \"mlbridge\";\n    @Override\n    public String name() {\n        return NAME;\n    }\n\n    private MailingListConfiguration parseList(JSONObject configuration) {\n        var listAddress = EmailAddress.parse(configuration.get(\"email\").asString());\n        Set<String> labels = configuration.contains(\"labels\") ?\n                configuration.get(\"labels\").stream()\n                             .map(JSONValue::asString)\n                             .collect(Collectors.toSet()) :\n                Set.of();\n        return new MailingListConfiguration(listAddress, labels);\n    }\n\n    private List<MailingListConfiguration> parseLists(JSONValue configuration) {\n        if (configuration.isArray()) {\n            return configuration.stream()\n                                .map(JSONValue::asObject)\n                                .map(this::parseList)\n                                .collect(Collectors.toList());\n        } else {\n            return List.of(parseList(configuration.asObject()));\n        }\n    }\n\n    @Override\n    public List<Bot> create(BotConfiguration configuration) {\n        var ret = new ArrayList<Bot>();\n        var specific = configuration.specific();\n\n        var from = EmailAddress.from(specific.get(\"name\").asString(), specific.get(\"mail\").asString());\n        var ignoredUsers = specific.get(\"ignored\").get(\"users\").stream()\n                                   .map(JSONValue::asString)\n                                   .collect(Collectors.toSet());\n        var ignoredComments = specific.get(\"ignored\").get(\"comments\").stream()\n                                      .map(JSONValue::asString)\n                                      .map(pattern -> Pattern.compile(pattern, Pattern.MULTILINE | Pattern.DOTALL))\n                                      .collect(Collectors.toSet());\n        var listArchive = URIBuilder.base(specific.get(\"server\").get(\"archive\").asString()).build();\n        String archiveType = null;\n        if (specific.get(\"server\").contains(\"type\")) {\n            archiveType = specific.get(\"server\").get(\"type\").asString();\n        }\n        var listSmtp = specific.get(\"server\").get(\"smtp\").asString();\n        var interval = specific.get(\"server\").contains(\"interval\") ?\n                Duration.parse(specific.get(\"server\").get(\"interval\").asString()) : Duration.ofSeconds(1);\n\n        var webrevHTMLRepo = configuration.repository(specific.get(\"webrevs\").get(\"repository\").get(\"html\").asString());\n        var webrevJSONRepo = configuration.repository(specific.get(\"webrevs\").get(\"repository\").get(\"json\").asString());\n        var webrevRef = specific.get(\"webrevs\").get(\"ref\").asString();\n        var webrevWeb = specific.get(\"webrevs\").get(\"web\").asString();\n\n        var archiveRepo = configuration.repository(specific.get(\"archive\").asString());\n        var archiveRef = configuration.repositoryRef(specific.get(\"archive\").asString());\n        var globalIssueTracker = URIBuilder.base(specific.get(\"issues\").asString()).build();\n\n        var readyLabels = specific.get(\"ready\").get(\"labels\").stream()\n                .map(JSONValue::asString)\n                .collect(Collectors.toSet());\n        var readyComments = specific.get(\"ready\").get(\"comments\").stream()\n                .map(JSONValue::asObject)\n                .collect(Collectors.toMap(obj -> obj.get(\"user\").asString(),\n                                          obj -> Pattern.compile(obj.get(\"pattern\").asString())));\n        var cooldown = specific.contains(\"cooldown\") ? Duration.parse(specific.get(\"cooldown\").asString()) : Duration.ofMinutes(1);\n        boolean useEtag = false;\n        if (specific.get(\"server\").contains(\"etag\")) {\n            useEtag = specific.get(\"server\").get(\"etag\").asBoolean();\n        }\n        MailingListServer mailmanServer = createMailmanServer(archiveType, listArchive, listSmtp, interval, useEtag);\n\n        var mailingListReaderMap = new HashMap<Set<EmailAddress>, MailingListReader>();\n\n        for (var repoConfig : specific.get(\"repositories\").asArray()) {\n            var repo = repoConfig.get(\"repository\").asString();\n            var hostedRepository = configuration.repository(repo);\n            var censusRepo = configuration.repository(repoConfig.get(\"census\").asString());\n            var censusRef = configuration.repositoryRef(repoConfig.get(\"census\").asString());\n\n            var issueTracker = globalIssueTracker;\n            if (repoConfig.contains(\"issues\")) {\n                issueTracker = URIBuilder.base(repoConfig.get(\"issues\").asString()).build();\n            }\n\n            Map<String, String> headers = repoConfig.contains(\"headers\") ?\n                    repoConfig.get(\"headers\").fields().stream()\n                              .collect(Collectors.toMap(JSONObject.Field::name, field -> field.value().asString())) :\n                    Map.of();\n\n            var lists = parseLists(repoConfig.get(\"lists\"));\n            if (!repoConfig.contains(\"bidirectional\") || repoConfig.get(\"bidirectional\").asBoolean()) {\n                var listsForReading = new HashSet<EmailAddress>();\n                for (var list : lists) {\n                    listsForReading.add(list.list());\n                }\n                // Reuse MailingListReaders with the exact same set of mailing lists between bots\n                // to benefit more from cached results.\n                if (!mailingListReaderMap.containsKey(listsForReading)) {\n                    mailingListReaderMap.put(listsForReading, mailmanServer.getListReader(listsForReading.toArray(new EmailAddress[0])));\n                }\n                var bot = new MailingListArchiveReaderBot(mailingListReaderMap.get(listsForReading), hostedRepository);\n                ret.add(bot);\n            }\n\n            var folder = repoConfig.contains(\"folder\") ? repoConfig.get(\"folder\").asString() : configuration.repositoryName(repo);\n\n            var webrevGenerateHTML = true;\n            if (repoConfig.contains(\"webrevs\") &&\n                repoConfig.get(\"webrevs\").contains(\"html\") &&\n                repoConfig.get(\"webrevs\").get(\"html\").asBoolean() == false) {\n                webrevGenerateHTML = false;\n            }\n            var webrevGenerateJSON = repoConfig.contains(\"webrevs\") &&\n                                     repoConfig.get(\"webrevs\").contains(\"json\") &&\n                                     repoConfig.get(\"webrevs\").get(\"json\").asBoolean();\n\n            var botBuilder = MailingListBridgeBot.newBuilder().from(from)\n                                                 .repo(hostedRepository)\n                                                 .archive(archiveRepo)\n                                                 .archiveRef(archiveRef)\n                                                 .censusRepo(censusRepo)\n                                                 .censusRef(censusRef)\n                                                 .lists(lists)\n                                                 .ignoredUsers(ignoredUsers)\n                                                 .ignoredComments(ignoredComments)\n                                                 .webrevStorageHTMLRepository(webrevHTMLRepo)\n                                                 .webrevStorageJSONRepository(webrevJSONRepo)\n                                                 .webrevStorageRef(webrevRef)\n                                                 .webrevStorageBase(Path.of(folder))\n                                                 .webrevStorageBaseUri(URIBuilder.base(webrevWeb).build())\n                                                 .webrevGenerateHTML(webrevGenerateHTML)\n                                                 .webrevGenerateJSON(webrevGenerateJSON)\n                                                 .readyLabels(readyLabels)\n                                                 .readyComments(readyComments)\n                                                 .issueTracker(issueTracker)\n                                                 .headers(headers)\n                                                 .cooldown(cooldown)\n                                                 .seedStorage(configuration.storageFolder().resolve(\"seeds\"))\n                                                 .mailingListServer(mailmanServer);\n\n            if (repoConfig.contains(\"reponame\")) {\n                botBuilder.repoInSubject(repoConfig.get(\"reponame\").asBoolean());\n            }\n            if (repoConfig.contains(\"branchname\")) {\n                botBuilder.branchInSubject(Pattern.compile(repoConfig.get(\"branchname\").asString()));\n            }\n            ret.add(botBuilder.build());\n        }\n\n        return ret;\n    }\n\n    private static MailingListServer createMailmanServer(String archiveType, URI listArchive, String listSmtp,\n            Duration sendInterval, boolean useEtag) {\n        MailingListServer mailmanServer;\n        if (archiveType == null || archiveType.equals(\"mailman2\")) {\n            mailmanServer = MailingListServerFactory.createMailman2Server(listArchive, listSmtp, sendInterval, useEtag);\n        } else if (archiveType.equals(\"mailman3\")) {\n            mailmanServer = MailingListServerFactory.createMailman3Server(listArchive, listSmtp, sendInterval);\n        } else {\n            throw new RuntimeException(\"Invalid server archive type: \" + archiveType);\n        }\n        return mailmanServer;\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/MailingListConfiguration.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.email.EmailAddress;\n\nimport java.util.*;\n\nclass MailingListConfiguration {\n    private EmailAddress list;\n    private Set<String> labels;\n\n    MailingListConfiguration(EmailAddress list, Set<String> labels) {\n        this.list = list;\n        this.labels = labels;\n    }\n\n    EmailAddress list() {\n        return list;\n    }\n\n    Set<String> labels() {\n        return labels;\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/MarkdownToText.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport java.util.regex.*;\n\npublic class MarkdownToText {\n    private static final Pattern EMOJI_PATTERN = Pattern.compile(\"(:([0-9a-z_+-]+):)\");\n    private static final Pattern SUGGESTION_PATTERN = Pattern.compile(\"^```suggestion$\", Pattern.MULTILINE);\n    private static final Pattern CODE_PATTERN = Pattern.compile(\"^```+(?:\\\\w+)?$\", Pattern.MULTILINE);\n    private static final Pattern ESCAPES_PATTERN = Pattern.compile(\"\\\\\\\\([!\\\"#$%&'()*+,\\\\-./:;<=?@\\\\[\\\\]^_`{|}~])\", Pattern.MULTILINE);\n    private static final Pattern ENTITIES_PATTERN = Pattern.compile(\"&#32;\", Pattern.MULTILINE);\n\n    private static String removeEmojis(String markdown) {\n        var emojiMatcher = EMOJI_PATTERN.matcher(markdown);\n        return emojiMatcher.replaceAll(mr -> EmojiTable.mapping.getOrDefault(mr.group(2), mr.group(1)));\n    }\n\n    private static String removeSuggestions(String markdown) {\n        var suggestionMatcher = SUGGESTION_PATTERN.matcher(markdown);\n        return suggestionMatcher.replaceAll(\"Suggestion:\\n\");\n    }\n\n    private static String removeCode(String markdown) {\n        var codeMatcher = CODE_PATTERN.matcher(markdown);\n        return codeMatcher.replaceAll(\"\");\n    }\n\n    static String removeEscapes(String markdown) {\n        var escapesMatcher = ESCAPES_PATTERN.matcher(markdown);\n        return escapesMatcher.replaceAll(mr -> Matcher.quoteReplacement(mr.group(1)));\n    }\n\n    static String removeEntities(String markdown) {\n        var entitiesMatcher = ENTITIES_PATTERN.matcher(markdown);\n        return entitiesMatcher.replaceAll(\" \");\n    }\n\n    static String removeFormatting(String markdown) {\n        return removeEscapes(removeEntities(removeCode(removeSuggestions(removeEmojis(markdown))))).strip();\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/QuoteFilter.java",
    "content": "/*\n * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport java.net.URI;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\nclass QuoteFilter {\n    private static final Pattern LEADING_QUOTES_PATTERN = Pattern.compile(\"^([>\\\\s]+).*\");\n\n    private static Optional<String> leadingQuotes(String line) {\n        var leadingQuotesMatcher = LEADING_QUOTES_PATTERN.matcher(line);\n        if (leadingQuotesMatcher.matches()) {\n            if (leadingQuotesMatcher.group(1).contains(\">\")) {\n                return Optional.of(leadingQuotesMatcher.group(1).trim());\n            }\n        }\n        return Optional.empty();\n    }\n\n    // Strip all quote blocks containing a certain link\n    public static String stripLinkBlock(String body, URI link) {\n        var ret = new ArrayList<String>();\n        var buffer = new LinkedList<String>();\n        String dropPrefix = null;\n\n        for (var line : body.split(\"\\\\R\")) {\n            if (dropPrefix != null && line.startsWith(dropPrefix)) {\n                continue;\n            }\n            dropPrefix = null;\n\n            if (line.contains(link.toString())) {\n                var prefix = leadingQuotes(line);\n                if (prefix.isEmpty()) {\n                    buffer.add(line);\n                    continue;\n                }\n                dropPrefix = prefix.get();\n\n                // Drop any previous lines with the same prefix\n                while (!buffer.isEmpty()) {\n                    if (buffer.peekLast().startsWith(dropPrefix)) {\n                        buffer.removeLast();\n                    } else {\n                        break;\n                    }\n                }\n                // Any remaining lines in buffer should be kept in the final result\n                ret.addAll(buffer);\n                buffer.clear();\n            } else {\n                buffer.add(line);\n            }\n        }\n\n        ret.addAll(buffer);\n        return String.join(\"\\n\", ret);\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/ReviewArchive.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.email.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.charset.StandardCharsets;\nimport java.security.*;\nimport java.time.*;\nimport java.util.*;\nimport java.util.function.Consumer;\nimport java.util.logging.Logger;\nimport java.util.regex.*;\nimport java.util.stream.*;\n\nclass ReviewArchive {\n    private final PullRequest pr;\n    private final EmailAddress sender;\n\n    private final List<Comment> comments = new ArrayList<>();\n    private final List<Comment> ignoredComments = new ArrayList<>();\n    private final List<Review> reviews = new ArrayList<>();\n    private final List<ReviewComment> reviewComments = new ArrayList<>();\n\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.mlbridge\");\n\n    ReviewArchive(PullRequest pr, EmailAddress sender) {\n        this.pr = pr;\n        this.sender = sender;\n    }\n\n    void addComment(Comment comment) {\n        comments.add(comment);\n    }\n\n    void addIgnored(Comment comment) {\n        ignoredComments.add(comment);\n    }\n\n    void addReview(Review review) {\n        reviews.add(review);\n    }\n\n    void addReviewComment(ReviewComment reviewComment) {\n        reviewComments.add(reviewComment);\n    }\n\n    // Searches for a previous reply to a certain parent by a specific author\n    private Optional<ArchiveItem> findPreviousReplyBy(List<ArchiveItem> generated, HostUser author, ArchiveItem parent) {\n        return generated.stream()\n                        .filter(item -> item.author().equals(author))\n                        .filter(item -> item.parent().isPresent())\n                        .filter(item -> item.parent().get().equals(parent))\n                        .findAny();\n    }\n\n    public static final Pattern PUSHED_PATTERN = Pattern.compile(\"Pushed as commit ([a-f0-9]{40})\\\\.\");\n\n    private static final int QUOTE_BODY_MAX_LENGTH = 2500;\n\n    private Optional<Hash> findIntegratedHash() {\n        return ignoredComments.stream()\n                              .map(Comment::body)\n                              .map(PUSHED_PATTERN::matcher)\n                              .filter(Matcher::find)\n                              .map(m -> m.group(1))\n                              .map(Hash::new)\n                              .findAny();\n    }\n\n    private boolean hasLegacyIntegrationNotice(Repository localRepo, Commit commit) {\n        // Commits before this date are assumed to have been serviced by the old PR notifier\n        return commit.authored().isBefore(ZonedDateTime.of(2020, 4, 28, 14, 0, 0, 0, ZoneId.of(\"UTC\")));\n    }\n\n    private List<ArchiveItem> generateArchiveItems(List<Email> sentEmails, Repository localRepo, URI issueTracker, String issuePrefix, HostUserToEmailAuthor hostUserToEmailAuthor, HostUserToUsername hostUserToUsername, HostUserToRole hostUserToRole, WebrevStorage.WebrevGenerator webrevGenerator, WebrevNotification webrevNotification, String subjectPrefix) throws IOException {\n        var generated = new ArrayList<ArchiveItem>();\n        Hash lastBase = null;\n        Hash lastHead = null;\n        int revisionIndex = 0;\n        var threadPrefix = \"RFR\";\n\n        if (!sentEmails.isEmpty()) {\n            var first = sentEmails.get(0);\n            if (first.hasHeader(\"PR-Thread-Prefix\")) {\n                threadPrefix = first.headerValue(\"PR-Thread-Prefix\");\n            }\n        } else {\n            if (pr.state() != Issue.State.OPEN) {\n                threadPrefix = \"Integrated\";\n            }\n        }\n\n        // Check existing generated mails to find which hashes have been previously reported\n        for (var email : sentEmails) {\n            if (email.hasHeader(\"PR-Base-Hash\")) {\n                var curBase = new Hash(email.headerValue(\"PR-Base-Hash\"));\n                var curHead = new Hash(email.headerValue(\"PR-Head-Hash\"));\n                var created = email.date();\n\n                if (generated.isEmpty()) {\n                    var first = ArchiveItem.from(pr, localRepo, hostUserToEmailAuthor, issueTracker, issuePrefix, webrevGenerator, webrevNotification, pr.createdAt(), pr.updatedAt(), curBase, curHead, subjectPrefix, threadPrefix);\n                    generated.add(first);\n                } else {\n                    var revision = ArchiveItem.from(pr, localRepo, hostUserToEmailAuthor, webrevGenerator, webrevNotification, created, created, lastBase, lastHead, curBase, curHead, ++revisionIndex, generated.get(0), subjectPrefix, threadPrefix);\n                    generated.add(revision);\n                }\n\n                lastBase = curBase;\n                lastHead = curHead;\n            }\n        }\n\n        // Check if we're at a revision not previously reported\n        var baseHash = PullRequestUtils.baseHash(pr, localRepo);\n        if (!pr.isDraft() && (!baseHash.equals(lastBase) || !pr.headHash().equals(lastHead))) {\n            if (generated.isEmpty()) {\n                var first = ArchiveItem.from(pr, localRepo, hostUserToEmailAuthor, issueTracker, issuePrefix, webrevGenerator, webrevNotification, pr.createdAt(), pr.updatedAt(), baseHash, pr.headHash(), subjectPrefix, threadPrefix);\n                generated.add(first);\n            } else {\n                var revision = ArchiveItem.from(pr, localRepo, hostUserToEmailAuthor, webrevGenerator, webrevNotification, pr.updatedAt(), pr.updatedAt(), lastBase, lastHead, baseHash, pr.headHash(), ++revisionIndex, generated.get(0), subjectPrefix, threadPrefix);\n                generated.add(revision);\n            }\n        }\n\n        // A review always have a revision mail as parent, so start with these\n        for (var review : reviews) {\n            var parent = ArchiveItem.findParent(generated, review);\n            var reply = ArchiveItem.from(pr, review, hostUserToEmailAuthor, hostUserToUsername, hostUserToRole, parent);\n            generated.add(reply);\n        }\n        // Comments can be a reply to a bridged email\n        var bridgedComments = new ArrayList<BridgedComment>();\n        for (var ignored : ignoredComments) {\n            var bridgedComment = BridgedComment.from(ignored, pr.repository().forge().currentUser());\n            bridgedComment.ifPresent(bridgedComments::add);\n        }\n        // Comments have either a comment or a review as parent, the eligible ones have been generated at this point\n        for (var comment : comments) {\n            var parent = ArchiveItem.findParent(generated, bridgedComments, comment);\n            var reply = ArchiveItem.from(pr, comment, hostUserToEmailAuthor, parent);\n            generated.add(reply);\n        }\n        // Finally, file specific comments should be seen after general review comments\n        for (var reviewComment : reviewComments) {\n            var parent = ArchiveItem.findParent(generated, reviewComments, reviewComment);\n            var reply = ArchiveItem.from(pr, reviewComment, hostUserToEmailAuthor, parent);\n            generated.add(reply);\n        }\n\n        // Post a closed notice for regular RFR threads that weren't integrated\n        if (pr.state() != Issue.State.OPEN) {\n            var parent = generated.get(0);\n            if (pr.labelNames().contains(\"integrated\")) {\n                var hash = findIntegratedHash();\n                if (hash.isPresent()) {\n                    var commit = localRepo.lookup(hash.get());\n                    if (commit.isPresent()) {\n                        if (!hasLegacyIntegrationNotice(localRepo, commit.get())) {\n                            var reply = ArchiveItem.integratedNotice(pr, localRepo, commit.get(), hostUserToEmailAuthor, parent, subjectPrefix);\n                            generated.add(reply);\n                        }\n                    } else {\n                        log.warning(\"Target commit for PR no longer exists, can't post or verify integration notice: \" + hash.get());\n                    }\n                } else {\n                    log.info(\"PR \" + pr.webUrl() + \" has integrated label but no integration comment, \" +\n                            \"can't post integration notice until it does\");\n                }\n            } else if (threadPrefix.equals(\"RFR\")) {\n                var reply = ArchiveItem.closedNotice(pr, hostUserToEmailAuthor, parent, subjectPrefix);\n                generated.add(reply);\n            }\n        }\n\n        return generated;\n    }\n\n    private Set<String> sentItemIds(List<Email> sentEmails) {\n        var primary = sentEmails.stream()\n                                .map(email -> getStableMessageId(email.id()));\n        var collapsed = sentEmails.stream()\n                                  .filter(email -> email.hasHeader(\"PR-Collapsed-IDs\"))\n                                  .flatMap(email -> Stream.of(email.headerValue(\"PR-Collapsed-IDs\").split(\" \")));\n        return Stream.concat(primary, collapsed)\n                     .collect(Collectors.toSet());\n    }\n\n    private String parentAuthorPath(ArchiveItem item) {\n        var ret = new StringBuilder();\n        ret.append(item.author().id());\n        ret.append(\":\");\n        ret.append(item.subject());\n        ret.append(\":\");\n        while (item.parent().isPresent()) {\n            item = item.parent().get();\n            ret.append(\".\");\n            ret.append(item.author().id());\n        }\n        return ret.toString();\n    }\n\n    // Group items that has the same author and the same parent\n    private List<List<ArchiveItem>> collapsableItems(List<ArchiveItem> items) {\n        var grouped = items.stream()\n                           .collect(Collectors.groupingBy(this::parentAuthorPath,\n                                                          LinkedHashMap::new, Collectors.toList()));\n        return new ArrayList<>(grouped.values());\n    }\n\n    private String quoteBody(String body) {\n        if (body.length() > QUOTE_BODY_MAX_LENGTH) {\n            body = body.substring(0, QUOTE_BODY_MAX_LENGTH) + \"...\";\n        }\n        return Arrays.stream(body.strip().split(\"\\\\R\"))\n                     .map(line -> line.length() > 0 ? line.charAt(0) == '>' ? \">\" + line : \"> \" + line : \"> \")\n                     .collect(Collectors.joining(\"\\n\"));\n    }\n\n    private List<ArchiveItem> parentsToQuote(ArchiveItem item, int quoteLevel, Set<ArchiveItem> alreadyQuoted) {\n        var ret = new ArrayList<ArchiveItem>();\n\n        if (item.parent().isPresent() && quoteLevel > 0 && !alreadyQuoted.contains(item.parent().get())) {\n            ret.add(item.parent().get());\n            ret.addAll(parentsToQuote(item.parent().get(), quoteLevel - 1, alreadyQuoted));\n        }\n\n        return ret;\n    }\n\n    // Parents to quote are provided with the newest item first. If the item already has quoted\n    // a parent, use that as the quote and return an empty string.\n    private String quoteSelectedParents(List<ArchiveItem> parentsToQuote, ArchiveItem first) {\n        if (parentsToQuote.isEmpty()) {\n            return \"\";\n        }\n        if (ArchiveItem.containsQuote(first.body(), parentsToQuote.get(0).body())) {\n            return \"\";\n        }\n        Collections.reverse(parentsToQuote);\n        var ret = \"\";\n        for (var parent : parentsToQuote) {\n            if (!ret.isBlank()) {\n                ret = quoteBody(ret) + \"\\n>\\n\" + quoteBody(parent.body());\n            } else {\n                ret = quoteBody(parent.body());\n            }\n        }\n        return ret;\n    }\n\n    private Email findArchiveItemEmail(ArchiveItem item, List<Email> sentEmails, List<Email> newEmails) {\n        // Check for the special \"bridged message\" item first\n        if (BridgedComment.isBridgedUser(item.author())) {\n            var first = sentEmails.size() > 0 ? sentEmails.get(0) : newEmails.get(0);\n            return Email.reply(first, item.subject(), item.body())\n                        .id(EmailAddress.from(item.id().substring(2)))\n                        .build();\n        }\n\n        var uniqueItemId = getUniqueMessageId(item.id());\n        var stableItemId = getStableMessageId(uniqueItemId);\n        return Stream.concat(sentEmails.stream(), newEmails.stream())\n                     .filter(email -> getStableMessageId(email.id()).equals(stableItemId) ||\n                             (email.hasHeader(\"PR-Collapsed-IDs\") && email.headerValue(\"PR-Collapsed-IDs\").contains(stableItemId)))\n                     .findAny()\n                     .orElseThrow();\n    }\n\n    private EmailAddress getUniqueMessageId(String identifier) {\n        try {\n            var prSpecific = pr.repository().name().replace(\"/\", \".\") + \".\" + pr.id();\n            var digest = MessageDigest.getInstance(\"SHA-256\");\n            digest.update(prSpecific.getBytes(StandardCharsets.UTF_8));\n            digest.update(identifier.getBytes(StandardCharsets.UTF_8));\n            var encodedCommon = Base64.getUrlEncoder().encodeToString(digest.digest());\n\n            return EmailAddress.from(encodedCommon + \".\" + UUID.randomUUID() + \"@\" + pr.repository().authenticatedUrl().getHost());\n        } catch (NoSuchAlgorithmException e) {\n            throw new RuntimeException(\"Cannot find SHA-256\");\n        }\n    }\n\n    private String getStableMessageId(EmailAddress uniqueMessageId) {\n        return uniqueMessageId.localPart().split(\"\\\\.\")[0];\n    }\n\n    List<Email> generateNewEmails(List<Email> sentEmails, Duration cooldown, Repository localRepo, URI issueTracker, String issuePrefix, WebrevStorage.WebrevGenerator webrevGenerator, WebrevNotification webrevNotification, HostUserToEmailAuthor hostUserToEmailAuthor, HostUserToUsername hostUserToUsername, HostUserToRole hostUserToRole, String subjectPrefix, Consumer<Instant> retryConsumer) throws IOException {\n        var ret = new ArrayList<Email>();\n        var allItems = generateArchiveItems(sentEmails, localRepo, issueTracker, issuePrefix, hostUserToEmailAuthor, hostUserToUsername, hostUserToRole, webrevGenerator, webrevNotification, subjectPrefix);\n        var sentItemIds = sentItemIds(sentEmails);\n        var unsentItems = allItems.stream()\n                                  .filter(item -> !sentItemIds.contains(getStableMessageId(getUniqueMessageId(item.id()))))\n                                  .collect(Collectors.toList());\n        if (unsentItems.isEmpty()) {\n            return ret;\n        }\n        var lastUpdate = unsentItems.stream()\n                                    .map(ArchiveItem::updatedAt)\n                                    .max(ZonedDateTime::compareTo).orElseThrow();\n        var mayUpdate = lastUpdate.plus(cooldown);\n        if (lastUpdate.plus(cooldown).isAfter(ZonedDateTime.now())) {\n            log.info(\"Waiting for new content to settle down - last update was at \" + lastUpdate);\n            log.info(\"Retry again after \" + mayUpdate);\n            retryConsumer.accept(mayUpdate.toInstant());\n            return ret;\n        }\n\n        var combinedItems = collapsableItems(unsentItems);\n        for (var itemList : combinedItems) {\n            var quotedParents = new HashSet<ArchiveItem>();\n\n            // Simply combine all message bodies together with unique quotes\n            var body = new StringBuilder();\n            for (var item : itemList) {\n                if (body.length() > 0) {\n                    body.append(\"\\n\\n\");\n                }\n                var newQuotes = parentsToQuote(item, 2, quotedParents);\n                var quote = quoteSelectedParents(newQuotes, item);\n                if (!quote.isBlank()) {\n                    body.append(quote);\n                    body.append(\"\\n\\n\");\n                }\n                quotedParents.addAll(newQuotes);\n                body.append(item.body());\n            }\n\n            // For footers, we want to combine all unique fragments\n            var footer = new StringBuilder();\n            var includedFooterFragments = new HashSet<String>();\n            for (var item : itemList) {\n                var newFooterFragments = Stream.of(item.footer().split(\"\\n\\n\"))\n                                               .filter(line -> !includedFooterFragments.contains(line))\n                                               .collect(Collectors.toList());\n                if (!footer.isEmpty() && !newFooterFragments.isEmpty()) {\n                    footer.append(\"\\n\");\n                }\n                footer.append(String.join(\"\\n\\n\", newFooterFragments));\n                includedFooterFragments.addAll(newFooterFragments);\n            }\n\n            // All items have parents from the same author after collapsing -> should have the same header\n            var firstItem = itemList.get(0);\n            var header = firstItem.header();\n\n            var combined = (header.isBlank() ? \"\" : header +  \"\\n\\n\") +\n                    body.toString().strip() +\n                    (footer.length() == 0 ? \"\" : \"\\n\\n-------------\\n\\n\" + footer.toString());\n\n            var emailBuilder = Email.create(firstItem.subject(), combined);\n            if (firstItem.parent().isPresent()) {\n                emailBuilder.reply(findArchiveItemEmail(firstItem.parent().get(), sentEmails, ret));\n            }\n            emailBuilder.sender(sender);\n            emailBuilder.author(hostUserToEmailAuthor.author(firstItem.author()));\n            emailBuilder.id(getUniqueMessageId(firstItem.id()));\n\n            var collapsedItems = itemList.stream()\n                                         .skip(1)\n                                         .map(item -> getStableMessageId(getUniqueMessageId(item.id())))\n                                         .collect(Collectors.toSet());\n            if (collapsedItems.size() > 0) {\n                emailBuilder.header(\"PR-Collapsed-IDs\", String.join(\" \", collapsedItems));\n            }\n            emailBuilder.headers(firstItem.extraHeaders());\n            var email = emailBuilder.build();\n            ret.add(email);\n        }\n\n        return ret;\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/TextToMarkdown.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport java.util.ArrayList;\nimport java.util.regex.*;\n\npublic class TextToMarkdown {\n    private static final Pattern PUNCTUATION_PATTERN = Pattern.compile(\"([!\\\"#$%&'()*+,\\\\-./:;<=?@\\\\[\\\\]^_`{|}~])\", Pattern.MULTILINE);\n    private static final Pattern INDENTED_PATTERN = Pattern.compile(\"^ {4}\", Pattern.MULTILINE);\n    private static final Pattern MENTION_PATTERN = Pattern.compile(\"@(\\\\w+)\", Pattern.MULTILINE);\n\n    private static String escapeBackslashes(String text) {\n        return text.replace(\"\\\\\", \"\\\\\\\\\");\n    }\n\n    private static String escapePunctuation(String text) {\n        var punctuationMatcher = PUNCTUATION_PATTERN.matcher(text);\n        return punctuationMatcher.replaceAll(mr -> \"\\\\\\\\\" + Matcher.quoteReplacement(mr.group(1)));\n    }\n\n    private static String escapeIndention(String text) {\n        var indentedMatcher = INDENTED_PATTERN.matcher(text);\n        return indentedMatcher.replaceAll(\"&#32;   \");\n    }\n\n    private static String separateQuoteBlocks(String text) {\n        var ret = new ArrayList<String>();\n        var lastLineQuoted = false;\n        for (var line : text.split(\"\\\\R\")) {\n            if ((line.length() > 0) && (line.charAt(0) == '>')) {\n                lastLineQuoted = true;\n            } else {\n                if (lastLineQuoted && !line.isBlank()) {\n                    ret.add(\"\");\n                }\n                lastLineQuoted = false;\n            }\n            ret.add(line);\n        }\n        return String.join(\"\\n\", ret);\n    }\n\n    private static String escapeMention(String text) {\n        var mentionMatcher = MENTION_PATTERN.matcher(text);\n        return mentionMatcher.replaceAll(\"@<!-- -->$1\");\n    }\n\n    static String escapeFormatting(String text) {\n        return escapeIndention(escapeMention(escapePunctuation(escapeBackslashes(separateQuoteBlocks(text)))));\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/WebrevDescription.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport java.net.URI;\n\npublic class WebrevDescription {\n    public enum Type {\n        FULL,\n        INCREMENTAL,\n        MERGE_TARGET,\n        MERGE_SOURCE,\n        MERGE_CONFLICT\n    }\n\n    private final URI uri;\n    private final Type type;\n    private final String description;\n    private final boolean diffTooLarge;\n\n    public WebrevDescription(URI uri, Type type, String description, boolean diffTooLarge) {\n        this.uri = uri;\n        this.type = type;\n        this.description = description;\n        this.diffTooLarge = diffTooLarge;\n    }\n\n    public WebrevDescription(URI uri, Type type, boolean diffTooLarge) {\n        this.uri = uri;\n        this.type = type;\n        this.description = null;\n        this.diffTooLarge = diffTooLarge;\n    }\n\n    public Type type() {\n        return type;\n    }\n\n    public URI uri() {\n        return uri;\n    }\n\n    public boolean diffTooLarge() {\n        return diffTooLarge;\n    }\n    public String label() {\n        switch (type) {\n            case FULL:\n                return \"Full\";\n            case INCREMENTAL:\n                return \"Incremental\";\n            case MERGE_TARGET:\n                return \"Merge target\" + (description != null ? \" (\" + description + \")\" : \"\");\n            case MERGE_SOURCE:\n                return \"Merge source\" + (description != null ? \" (\" + description + \")\" : \"\");\n            case MERGE_CONFLICT:\n                return \"Merge conflicts\" + (description != null ? \" (\" + description + \")\" : \"\");\n\n        }\n        throw new RuntimeException(\"Unknown type\");\n    }\n\n    public String shortLabel() {\n        switch (type) {\n            case FULL:\n                return \"full\";\n            case INCREMENTAL:\n                return \"incr\";\n            case MERGE_TARGET:\n                return description != null ? description : \"merge target\";\n            case MERGE_SOURCE:\n                return description != null ? description : \"merge source\";\n            case MERGE_CONFLICT:\n                return \"merge conflicts\";\n\n        }\n        throw new RuntimeException(\"Unknown type\");\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/WebrevNotification.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport java.util.List;\n\n@FunctionalInterface\ninterface WebrevNotification {\n    void notify(int index, List<WebrevDescription> webrevDescriptions);\n}\n"
  },
  {
    "path": "bots/mlbridge/src/main/java/org/openjdk/skara/bots/mlbridge/WebrevStorage.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.IssueTracker;\nimport org.openjdk.skara.jcheck.JCheckConfiguration;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.Issue;\nimport org.openjdk.skara.version.Version;\nimport org.openjdk.skara.webrev.DiffTooLargeException;\nimport org.openjdk.skara.webrev.Webrev;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.net.http.*;\nimport java.nio.file.*;\nimport java.time.*;\nimport java.util.*;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\nclass WebrevStorage {\n    private final HostedRepository htmlStorage;\n    private final HostedRepository jsonStorage;\n    private final String storageRef;\n    private final Path baseFolder;\n    private final URI baseUri;\n    private final EmailAddress author;\n    private final boolean generateHTML;\n    private final boolean generateJSON;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.mlbridge\");\n    private static final HttpClient client = HttpClient.newBuilder()\n                                                       .connectTimeout(Duration.ofSeconds(10))\n                                                       .build();\n\n    WebrevStorage(HostedRepository htmlStorage,\n                  String ref,\n                  Path baseFolder,\n                  URI baseUri,\n                  EmailAddress author) {\n        this.baseFolder = baseFolder;\n        this.baseUri = baseUri;\n        this.htmlStorage = htmlStorage;\n        this.jsonStorage = null;\n        storageRef = ref;\n        this.author = author;\n        this.generateHTML = true;\n        this.generateJSON = false;\n    }\n\n    WebrevStorage(HostedRepository htmlStorage,\n                  HostedRepository jsonStorage,\n                  String ref,\n                  Path baseFolder,\n                  URI baseUri,\n                  EmailAddress author,\n                  boolean generateHTML,\n                  boolean generateJSON) {\n        this.baseFolder = baseFolder;\n        this.baseUri = baseUri;\n        this.htmlStorage = htmlStorage;\n        this.jsonStorage = jsonStorage;\n        storageRef = ref;\n        this.author = author;\n        this.generateHTML = generateHTML;\n        this.generateJSON = generateJSON;\n    }\n\n    private void generateHTML(PullRequest pr, ReadOnlyRepository localRepository, Path folder, Diff diff, Hash base, Hash head) throws IOException, DiffTooLargeException {\n        Files.createDirectories(folder);\n        var fullName = pr.author().fullName();\n        var builder = Webrev.repository(localRepository)\n                            .output(folder)\n                            .version(Version.fromManifest().orElse(\"unknown\"))\n                            .upstream(pr.repository().webUrl().toString())\n                            .pullRequest(pr.webUrl().toString())\n                            .username(fullName);\n\n        var issue = Issue.fromStringRelaxed(pr.title());\n        if (issue.isPresent()) {\n            var conf = JCheckConfiguration.from(localRepository, head);\n            if (!conf.isEmpty()) {\n                var project = conf.get().general().jbs() != null ? conf.get().general().jbs() : conf.get().general().project();\n                var id = issue.get().shortId();\n                IssueTracker issueTracker = null;\n                try {\n                    issueTracker = IssueTracker.from(\"jira\", URI.create(\"https://bugs.openjdk.org\"));\n                } catch (RuntimeException e) {\n                    log.warning(\"Failed to create Jira issue tracker\");\n                }\n                if (issueTracker != null) {\n                    var hostedIssue = issueTracker.project(project).issue(id);\n                    if (hostedIssue.isPresent()) {\n                        builder = builder.issue(hostedIssue.get().webUrl().toString());\n                    }\n                }\n            }\n        }\n\n        if (diff != null) {\n            builder.generate(diff);\n        } else {\n            builder.generate(base, head);\n        }\n    }\n\n    private void generateJSON(PullRequest pr, ReadOnlyRepository localRepository, Path folder, Diff diff, Hash base, Hash head) throws IOException, DiffTooLargeException {\n        Files.createDirectories(folder);\n        var builder = Webrev.repository(localRepository)\n                            .output(folder)\n                            .upstream(pr.repository().webUrl(), pr.repository().name());\n        var sourceRepository = pr.sourceRepository();\n        if (sourceRepository.isEmpty()) {\n            throw new IllegalArgumentException(\"Cannot generate JSON for PR without source repository. PR: \" + pr.id() + \", repo: \" + pr.repository().webUrl());\n        }\n        builder.fork(sourceRepository.get().webUrl(), sourceRepository.get().name());\n\n        if (diff != null) {\n            builder.generateJSON(diff);\n        } else {\n            builder.generateJSON(base, head);\n        }\n    }\n\n    private String generatePlaceholder(PullRequest pr, Hash base, Hash head) {\n        return \"This file was too large to be included in the published webrev, and has been replaced with \" +\n                \"this placeholder message. It is possible to generate the original content locally by \" +\n                \"following these instructions:\\n\\n\" +\n                \"  $ git fetch \" + pr.repository().url() + \" \" + pr.fetchRef() + \"\\n\" +\n                \"  $ git checkout \" + head.hex() + \"\\n\" +\n                \"  $ git webrev -r \" + base.hex() + \"\\n\";\n    }\n\n    private void replaceContent(Path file, String placeholder) {\n        try {\n            if (file.getFileName().toString().endsWith(\".html\")) {\n                var existing = Files.readString(file);\n                var headerEnd = existing.indexOf(\"<pre>\");\n                var footerStart = existing.lastIndexOf(\"</pre>\");\n                if ((headerEnd > 0) && (footerStart > 0)) {\n                    var header = existing.substring(0, headerEnd + 5);\n                    var footer = existing.substring(footerStart);\n                    Files.writeString(file, header + placeholder + footer);\n                    return;\n                }\n            }\n            Files.writeString(file, placeholder);\n        } catch (IOException e) {\n            throw new RuntimeException(\"Failed to replace large file with placeholder\");\n        }\n    }\n\n    private boolean shouldBeReplaced(Path file) {\n        var neverReplace = Set.of(\n            \"index.html\",\n            \"comparison.json\",\n            \"commits.json\",\n            \"metadata.json\"\n        );\n        try {\n            if (neverReplace.contains(file.getFileName().toString())) {\n                return false;\n            } else {\n                return Files.size(file) >= 1000 * 1000;\n            }\n        } catch (IOException e) {\n            return false;\n        }\n    }\n\n    private void push(Repository localStorage, URI remote, Path webrevFolder, String identifier, String placeholder) throws IOException {\n        var batchIndex = new AtomicInteger();\n\n        // Replace large files (except the index) with placeholders\n        try (var files = Files.walk(webrevFolder)) {\n            files.filter(Files::isRegularFile)\n                 .filter(this::shouldBeReplaced)\n                 .forEach(file -> replaceContent(file, placeholder));\n        }\n\n        // Try to push 1000 files at a time\n        try (var files = Files.walk(webrevFolder)) {\n            var batches = files.filter(Files::isRegularFile)\n                               .collect(Collectors.groupingBy(path -> {\n                                   int curIndex = batchIndex.incrementAndGet();\n                                   return Math.floorDiv(curIndex, 1000);\n                               }));\n\n            for (var batch : batches.entrySet()) {\n                localStorage.add(batch.getValue());\n                Hash hash;\n                var message = \"Added webrev for \" + identifier +\n                        (batches.size() > 1 ? \" (\" + (batch.getKey() + 1) + \"/\" + batches.size() + \")\" : \"\");\n                try {\n                    hash = localStorage.commit(message, author.fullName().orElseThrow(), author.address());\n                } catch (IOException e) {\n                    // If the commit fails, it probably means that we're resuming a partially completed previous update\n                    // where some of the files have already been committed. Ignore it and continue.\n                    continue;\n                }\n                var retryCount = 0;\n                while (true) {\n                    try {\n                        localStorage.push(hash, remote, storageRef);\n                        break;\n                    } catch (IOException e) {\n                        retryCount++;\n                        if (retryCount > 5) {\n                            throw e;\n                        }\n                        var updated = localStorage.fetch(remote, storageRef).orElseThrow();\n                        localStorage.rebase(updated, author.fullName().orElseThrow(), author.address());\n                        hash = localStorage.head();\n                    }\n                }\n            }\n        }\n    }\n\n    private static void clearDirectory(Path directory) {\n        try (var files = Files.walk(directory)) {\n            files.map(Path::toFile)\n                 .sorted(Comparator.reverseOrder())\n                 .forEach(File::delete);\n        } catch (IOException io) {\n            throw new RuntimeException(io);\n        }\n    }\n\n    private void awaitPublication(URI uri, Duration timeout) throws IOException {\n        var end = Instant.now().plus(timeout);\n        var uriBuilder = URIBuilder.base(uri);\n\n        while (Instant.now().isBefore(end)) {\n            var uncachedUri = uriBuilder.setQuery(Map.of(\"nocache\", List.of(UUID.randomUUID().toString()))).build();\n            log.fine(\"Validating webrev URL: \" + uncachedUri);\n            var request = HttpRequest.newBuilder(uncachedUri)\n                                     .timeout(Duration.ofSeconds(30))\n                                     .GET()\n                                     .build();\n            try {\n                var response = client.send(request, HttpResponse.BodyHandlers.ofString());\n                if (response.statusCode() < 300) {\n                    log.info(response.statusCode() + \" when checking \" + uncachedUri + \" - success!\");\n                    return;\n                }\n                if (response.statusCode() < 400) {\n                    var newLocation = response.headers().firstValue(\"location\");\n                    if (newLocation.isPresent()) {\n                        log.info(\"Webrev url redirection: \" + newLocation.get());\n                        uriBuilder = URIBuilder.base(newLocation.get());\n                        continue;\n                    }\n                }\n                log.info(response.statusCode() + \" when checking \" + uncachedUri + \" - waiting...\");\n                Thread.sleep(Duration.ofSeconds(10));\n            } catch (InterruptedException ignored) {\n            }\n        }\n\n        throw new RuntimeException(\"No success response from \" + uri + \" within \" + timeout);\n    }\n\n    private URI createAndArchive(PullRequest pr, Repository localRepository, Path jsonScratchPath, Path htmlScratchPath,\n                                 Diff diff, Hash base, Hash head, String identifier,\n                                 Repository jsonLocalStorage, Repository htmlLocalStorage) throws DiffTooLargeException {\n        try {\n            if (!generateHTML && !generateJSON) {\n                return null;\n            }\n            var relativeFolder = baseFolder.resolve(String.format(\"%s/%s\", pr.id(), identifier));\n            var placeholder = generatePlaceholder(pr, base, head);\n            URI uri = null;\n\n            if (generateJSON) {\n                var outputFolder = jsonScratchPath.resolve(relativeFolder);\n                if (Files.exists(outputFolder)) {\n                    clearDirectory(outputFolder);\n                }\n                generateJSON(pr, localRepository, outputFolder, diff, base, head);\n                if (!jsonLocalStorage.isClean()) {\n                    push(jsonLocalStorage, jsonStorage.authenticatedUrl(), outputFolder, baseFolder.resolve(pr.id()).toString(), placeholder);\n                }\n                var repoName = Path.of(pr.repository().name()).getFileName();\n                uri = URI.create(baseUri.toString() + \"?repo=\" + repoName + \"&pr=\" + pr.id() + \"&range=\" + identifier);\n            }\n            if (generateHTML) {\n                var outputFolder = htmlScratchPath.resolve(relativeFolder);\n                if (Files.exists(outputFolder)) {\n                    clearDirectory(outputFolder);\n                }\n                generateHTML(pr, localRepository, outputFolder, diff, base, head);\n                if (!htmlLocalStorage.isClean()) {\n                    push(htmlLocalStorage, htmlStorage.authenticatedUrl(), outputFolder, baseFolder.resolve(pr.id()).toString(), placeholder);\n                }\n                uri = URIBuilder.base(baseUri).appendPath(relativeFolder.toString().replace('\\\\', '/')).build();\n                awaitPublication(uri, Duration.ofMinutes(30));\n            }\n            return uri;\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n    interface WebrevGenerator {\n        WebrevDescription generate(Hash base, Hash head, String identifier, WebrevDescription.Type type);\n\n        WebrevDescription generate(Diff diff, String identifier, WebrevDescription.Type type, String description);\n    }\n\n    WebrevGenerator generator(PullRequest pr, Repository localRepository, Path jsonScratchPath, Path htmlScratchPath,\n                              HostedRepositoryPool hostedRepositoryPool) {\n\n        return new WebrevGenerator() {\n            Repository jsonLocalStorage = null;\n            Repository htmlLocalStorage = null;\n\n            private void initializeLocalStorage() {\n                try {\n                    if (generateJSON && jsonLocalStorage == null && !(jsonStorage == null)) {\n                        jsonLocalStorage = hostedRepositoryPool.checkout(jsonStorage, storageRef, jsonScratchPath);\n                    }\n                    if (generateHTML && htmlLocalStorage == null && !(htmlStorage == null)) {\n                        htmlLocalStorage = hostedRepositoryPool.checkout(htmlStorage, storageRef, htmlScratchPath);\n                    }\n                } catch (IOException e) {\n                    throw new UncheckedIOException(e);\n                }\n            }\n\n            @Override\n            public WebrevDescription generate(Hash base, Hash head, String identifier, WebrevDescription.Type type) {\n                initializeLocalStorage();\n                try {\n                    var uri = createAndArchive(pr, localRepository, jsonScratchPath, htmlScratchPath, null, base, head, identifier, jsonLocalStorage, htmlLocalStorage);\n                    return new WebrevDescription(uri, type, false);\n                } catch (DiffTooLargeException e) {\n                    return new WebrevDescription(null, type, true);\n                }\n            }\n\n            @Override\n            public WebrevDescription generate(Diff diff, String identifier, WebrevDescription.Type type, String description) {\n                initializeLocalStorage();\n                try {\n                    var uri = createAndArchive(pr, localRepository, jsonScratchPath, htmlScratchPath, diff, diff.from(), diff.to(), identifier, jsonLocalStorage, htmlLocalStorage);\n                    return new WebrevDescription(uri, type, description, false);\n                } catch (DiffTooLargeException e) {\n                    return new WebrevDescription(null, type, description, true);\n                }\n            }\n        };\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/test/java/org/openjdk/skara/bots/mlbridge/ArchiveItemTests.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.Repository;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.time.ZonedDateTime;\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\npublic class ArchiveItemTests {\n    private int curId = 0;\n\n    private Comment createComment(HostUser user, String body) {\n        return new Comment(Integer.toString(curId++), body, user, ZonedDateTime.now(), ZonedDateTime.now());\n    }\n\n    private ArchiveItem fromPullRequest(PullRequest pr, Repository repo) throws IOException {\n        var base = repo.resolve(\"master\").orElseThrow();\n        return ArchiveItem.from(pr, repo, null, URI.create(\"http://www.example.com\"), \"\", null, null, ZonedDateTime.now(), ZonedDateTime.now(), base, base, \"\", \"\");\n    }\n\n    private ArchiveItem fromComment(PullRequest pr, Comment comment) {\n        return ArchiveItem.from(pr, comment, null, null);\n    }\n\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var localRepo = CheckableRepository.init(tempFolder.path(), repo.repositoryType());\n            var pr = credentials.createPullRequest(repo, \"master\", \"master\", \"Test\");\n\n            var user1 = HostUser.create(\"1\", \"user1\", \"User Uno\");\n            var user2 = HostUser.create(\"2\", \"user2\", \"User Duo\");\n            var user3 = HostUser.create(\"3\", \"user3\", \"User Trio\");\n\n            var c1 = createComment(user1, \"First comment\\nwith two lines\");\n            var c2 = createComment(user2, \"Second comment\");\n\n            var a0 = fromPullRequest(pr, localRepo);\n            var a1 = fromComment(pr, c1);\n            var a2 = fromComment(pr, c2);\n\n            assertEquals(a0, ArchiveItem.findParent(List.of(a0, a1, a2), List.of(), createComment(user3, \"Plain unrelated reply\")));\n\n            assertEquals(a1, ArchiveItem.findParent(List.of(a0, a1, a2), List.of(), createComment(user3, \"> First comment\\n\\nI agree\")));\n            assertEquals(a1, ArchiveItem.findParent(List.of(a0, a1, a2), List.of(), createComment(user3, \"> First comment\\n>with two lines\\n\\nI agree\")));\n            assertEquals(a1, ArchiveItem.findParent(List.of(a0, a1, a2), List.of(), createComment(user3, \"\\n> First comment\\n\\nI agree\")));\n\n            assertEquals(a1, ArchiveItem.findParent(List.of(a0, a1, a2), List.of(), createComment(user3, \"@user1 I agree\")));\n            assertEquals(a1, ArchiveItem.findParent(List.of(a0, a1, a2), List.of(), createComment(user3, \"@user1\\nI agree\")));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/test/java/org/openjdk/skara/bots/mlbridge/BridgedCommentTests.java",
    "content": "/*\n * Copyright (c) 2021, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class BridgedCommentTests {\n\n    @Test\n    public void bridgeMailPattern() {\n        assertFalse(BridgedComment.BRIDGED_MAIL_ID.matcher(\"foo\").find());\n        assertFalse(BridgedComment.BRIDGED_MAIL_ID.matcher(\"<-- foo -->\").find());\n        assertTrue(BridgedComment.BRIDGED_MAIL_ID.matcher(\"<!-- Bridged id (foo=) -->\").find());\n        assertTrue(BridgedComment.BRIDGED_MAIL_ID.matcher(\"<!-- Bridged id (PEEzNDJBNUQwLTM\" +\n                \"4MjItNEM2Ni05MDc4LUY5QThDOTA2NTRBNkBjYmZpZGRsZS5jb20+RnJvbSB0aGUgQ1NSIHJldmlldzo=) -->\").find());\n\n        var matcher = BridgedComment.BRIDGED_MAIL_ID.matcher(\"<!-- Bridged id (fo+/o=) -->\");\n        assertTrue(matcher.find());\n        assertEquals(\"fo+/o=\", matcher.group(1));\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/test/java/org/openjdk/skara/bots/mlbridge/LabelsUpdaterTests.java",
    "content": "/*\n * Copyright (c) 2021, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.util.List;\nimport java.util.Set;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\npublic class LabelsUpdaterTests {\n\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var listServer = TestMailmanServer.createV2();) {\n            var targetRepo = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var mlBot = MailingListBridgeBot.newBuilder()\n                    .repo(targetRepo)\n                    .lists(List.of(new MailingListConfiguration(listAddress, Set.of(\"foo\", \"bar\"))))\n                    .build();\n\n            // Check that the repo contains no labels\n            assertTrue(targetRepo.labels().isEmpty(), \"Repo has labels from the start: \" + targetRepo.labels());\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            assertEquals(2, targetRepo.labels().size(), \"Wrong number of labels\");\n            assertTrue(targetRepo.labels().stream()\n                    .anyMatch(l -> l.name().equals(\"foo\") && l.description().orElseThrow().equals(listAddress.address())),\n                    \"No label 'foo' found\");\n            assertTrue(targetRepo.labels().stream()\n                            .anyMatch(l -> l.name().equals(\"bar\") && l.description().orElseThrow().equals(listAddress.address())),\n                    \"No label 'bar' found\");\n\n            // Run again and expect no change\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            assertEquals(2, targetRepo.labels().size(), \"Wrong number of labels\");\n        }\n    }\n\n    @Test\n    void update(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var listServer = TestMailmanServer.createV2();) {\n            var targetRepo = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var listAddress2 = listServer.createList(\"test2\");\n            var mlBot = MailingListBridgeBot.newBuilder()\n                    .repo(targetRepo)\n                    .lists(List.of(new MailingListConfiguration(listAddress, Set.of(\"foo\"))))\n                    .build();\n\n            // Check that the repo contains no labels\n            assertTrue(targetRepo.labels().isEmpty(), \"Repo has labels from the start: \" + targetRepo.labels());\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            assertEquals(1, targetRepo.labels().size(), \"Wrong number of labels\");\n            assertTrue(targetRepo.labels().stream()\n                            .anyMatch(l -> l.name().equals(\"foo\") && l.description().orElseThrow().equals(listAddress.address())),\n                    \"No label 'foo' found\");\n\n            var mlBot2 = MailingListBridgeBot.newBuilder()\n                    .repo(targetRepo)\n                    .lists(List.of(new MailingListConfiguration(listAddress2, Set.of(\"foo\"))))\n                    .build();\n\n            // Run second bot and expect label to have updated\n            TestBotRunner.runPeriodicItems(mlBot2);\n\n            assertEquals(1, targetRepo.labels().size(), \"Wrong number of labels\");\n            assertTrue(targetRepo.labels().stream()\n                            .anyMatch(l -> l.name().equals(\"foo\") && l.description().orElseThrow().equals(listAddress2.address())),\n                    \"No label 'foo' found\");\n        }\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/test/java/org/openjdk/skara/bots/mlbridge/MailingListArchiveReaderBotTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.email.*;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.mailinglist.*;\nimport org.openjdk.skara.test.*;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass MailingListArchiveReaderBotTests {\n    private void addReply(Conversation conversation, EmailAddress recipient, MailingListServer mailingListServer, PullRequest pr, String reply) {\n        var first = conversation.first();\n        var references = first.id().toString();\n        var email = Email.create(EmailAddress.from(\"Commenter\", \"c@test.test\"), \"Re: RFR: \" + pr.title(), reply)\n                         .recipient(recipient)\n                         .id(EmailAddress.from(UUID.randomUUID() + \"@id.id\"))\n                         .header(\"In-Reply-To\", first.id().toString())\n                         .header(\"References\", references)\n                         .build();\n        mailingListServer.post(email);\n    }\n\n    private void addReply(Conversation conversation, EmailAddress recipient, MailingListServer mailingListServer, PullRequest pr) {\n        addReply(conversation, recipient, mailingListServer, pr, \"Looks good\");\n    }\n\n    @Test\n    void simpleArchive(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var readerBot = new MailingListArchiveReaderBot(mailmanList, archive);\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This should now be ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(readerBot);\n            TestBotRunner.runPeriodicItems(readerBot);\n\n            // Post a reply directly to the list\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            addReply(conversations.get(0), listAddress, mailmanServer, pr);\n            listServer.processIncoming();\n\n            // Another archive reader pass - has to be done twice\n            TestBotRunner.runPeriodicItems(readerBot);\n            TestBotRunner.runPeriodicItems(readerBot);\n\n            // The bridge should now have processed the reply\n            var updated = pr.comments();\n            assertEquals(2, updated.size());\n            assertTrue(updated.get(1).body().contains(\"Mailing list message from\"));\n            assertTrue(updated.get(1).body().contains(\"[Commenter](mailto:c@test.test)\"));\n            assertTrue(updated.get(1).body().contains(\"[test](mailto:test@\" + listAddress.domain() + \")\"));\n        }\n    }\n\n    @Test\n    void rememberBridged(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .repoInSubject(true)\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var readerBot = new MailingListArchiveReaderBot(mailmanList, archive);\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This should now be ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Post a reply directly to the list\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            addReply(conversations.get(0), listAddress, mailmanServer, pr);\n            listServer.processIncoming();\n\n            // Another archive reader pass - has to be done twice\n            TestBotRunner.runPeriodicItems(readerBot);\n            TestBotRunner.runPeriodicItems(readerBot);\n\n            // The bridge should now have processed the reply\n            var updated = pr.comments();\n            assertEquals(2, updated.size());\n\n            var newReaderBot = new MailingListArchiveReaderBot(mailmanList, archive);\n            TestBotRunner.runPeriodicItems(newReaderBot);\n            TestBotRunner.runPeriodicItems(newReaderBot);\n\n            // The new bridge should not have made duplicate posts\n            var notUpdated = pr.comments();\n            assertEquals(2, notUpdated.size());\n        }\n    }\n\n    @Test\n    void largeEmail(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            // The mailing list as well\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var readerBot = new MailingListArchiveReaderBot(mailmanList, archive);\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This should now be ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(readerBot);\n            TestBotRunner.runPeriodicItems(readerBot);\n\n            // Post a large reply directly to the list\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n\n            var replyBody = \"This line is about 30 bytes long\\n\".repeat(1000 * 10);\n            addReply(conversations.get(0), listAddress, mailmanServer, pr, replyBody);\n            listServer.processIncoming();\n\n            // Another archive reader pass - has to be done twice\n            TestBotRunner.runPeriodicItems(readerBot);\n            TestBotRunner.runPeriodicItems(readerBot);\n\n            // The bridge should now have processed the reply\n            var updated = pr.comments();\n            assertEquals(2, updated.size());\n            assertTrue(updated.get(1).body().contains(\"Mailing list message from\"));\n            assertTrue(updated.get(1).body().contains(\"[Commenter](mailto:c@test.test)\"));\n            assertTrue(updated.get(1).body().contains(\"[test](mailto:test@\" + listAddress.domain() + \")\"));\n            assertTrue(updated.get(1).body().contains(\"This message was too large\"));\n        }\n    }\n\n    /**\n     * Verify that we don't throw exceptions if the target branch of a PR is missing after\n     * being closed.\n     */\n    @Test\n    void branchMissing(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                    .from(from)\n                    .repo(author)\n                    .archive(archive)\n                    .censusRepo(censusBuilder.build())\n                    .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                    .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                    .webrevStorageHTMLRepository(archive)\n                    .webrevStorageRef(\"webrev\")\n                    .webrevStorageBase(Path.of(\"test\"))\n                    .webrevStorageBaseUri(webrevServer.uri())\n                    .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                    .mailingListServer(mailmanServer)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"to_be_deleted\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                    \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"to_be_deleted\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This should now be ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Delete the branch and close the PR\n            author.deleteBranch(\"to_be_deleted\");\n            pr.setState(Issue.State.CLOSED);\n\n            TestBotRunner.runPeriodicItems(mlBot);\n        }\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/test/java/org/openjdk/skara/bots/mlbridge/MailingListBridgeBotFactoryTest.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.bot.Bot;\nimport org.openjdk.skara.json.JWCC;\nimport org.openjdk.skara.mailinglist.MailingListReader;\nimport org.openjdk.skara.mailinglist.MailingListServer;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestBotFactory;\nimport org.openjdk.skara.test.TestHost;\nimport org.openjdk.skara.test.TestHostedRepository;\n\nimport java.time.Duration;\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass MailingListBridgeBotFactoryTest {\n    @Test\n    public void testCreate() {\n        try (var tempFolder = new TemporaryDirectory()) {\n            String jsonString = \"\"\"\n                    {\n                      \"name\": \"test\",\n                      \"mail\": \"test@openjdk.org\",\n                      \"ignored\": {\n                        \"users\": [\n                          \"ignore1[bot]\",\n                          \"ignore2[bot]\",\n                          \"ignore3[bot]\",\n                          \"ignore4[bot]\"\n                        ],\n                        \"comments\": [\n                          \"<!-- It's a test comment!-->\"\n                        ]\n                      },\n                      \"ready\": {\n                        \"labels\": [\n                          \"rfr\"\n                        ],\n                        \"comments\": [\n                          {\n                            \"user\": \"test_user[bot]\",\n                            \"pattern\": \"<!-- Welcome message -->\"\n                          }\n                        ]\n                      },\n                      \"server\": {\n                        \"archive\": \"https://mail.test.org/test/\",\n                        \"smtp\": \"0.0.0.0\",\n                        \"interval\": \"PT5S\",\n                        \"etag\": true,\n                      },\n                      \"webrevs\": {\n                        \"repository\": {\n                          \"html\": \"repo1\",\n                          \"json\": \"repo2\"\n                        },\n                        \"ref\": \"master\",\n                        \"web\": \"https://test.openjdk.org/\"\n                      },\n                      \"archive\": \"archive:master\",\n                      \"issues\": \"https://bugs.test.org/browse/\",\n                      \"cooldown\": \"PT2M\",\n                      \"repositories\": [\n                        {\n                          \"repository\": \"repo3\",\n                          \"census\": \"census:master\",\n                          \"webrevs\": {\n                            \"html\": false,\n                            \"json\": true\n                          },\n                          \"headers\": {\n                            \"Approved\": \"test\"\n                          },\n                          \"lists\": [\n                            {\n                              \"email\": \"test_email1@test.org\"\n                            }\n                          ],\n                          \"branchname\":\"dev\"\n                        },\n                        {\n                          \"repository\": \"repo4\",\n                          \"census\": \"census:master\",\n                          \"webrevs\": {\n                            \"html\": false,\n                            \"json\": true\n                          },\n                          \"lists\": {\n                            \"email\": \"test_email2@test.com\"\n                          },\n                          \"bidirectional\": false,\n                          \"reponame\": true\n                        },\n                        {\n                          \"repository\": \"repo5\",\n                          \"census\": \"census:master\",\n                          \"webrevs\": {\n                            \"html\": false,\n                            \"json\": true\n                          },\n                          \"headers\": {\n                            \"Approved\": \"test5\"\n                          },\n                          \"reponame\": true,\n                          \"branchname\": \"master\",\n                          \"lists\": [\n                            {\n                              \"email\": \"test_email3@test.org\",\n                              \"labels\": [\n                                \"label1\",\n                                \"label2\",\n                                \"label3\"\n                              ]\n                            }\n                          ],\n                          \"issues\": \"https://test.test.com/issueProject\"\n                        }\n                      ]\n                    }\n                    \"\"\";\n            var jsonConfig = JWCC.parse(jsonString).asObject();\n\n            var testHost = TestHost.createNew(List.of());\n            var testBotFactory = TestBotFactory.newBuilder()\n                    .addHostedRepository(\"repo1\", new TestHostedRepository(testHost, \"repo1\"))\n                    .addHostedRepository(\"repo2\", new TestHostedRepository(testHost, \"repo2\"))\n                    .addHostedRepository(\"repo3\", new TestHostedRepository(testHost, \"repo3\"))\n                    .addHostedRepository(\"repo4\", new TestHostedRepository(testHost, \"repo4\"))\n                    .addHostedRepository(\"repo5\", new TestHostedRepository(testHost, \"repo5\"))\n                    .addHostedRepository(\"archive\", new TestHostedRepository(\"archive\"))\n                    .addHostedRepository(\"census\", new TestHostedRepository(\"census\"))\n                    .storagePath(tempFolder.path().resolve(\"storage\"))\n                    .build();\n\n            var bots = testBotFactory.createBots(MailingListBridgeBotFactory.NAME, jsonConfig);\n            assertEquals(5, bots.size());\n\n            //A mailingListArchiveReaderBot for every configured repository which is bidirectional\n            List<Bot> mailingListArchiveReaderBots = bots.stream().filter(e -> e.getClass().equals(MailingListArchiveReaderBot.class)).toList();\n            //A mailingListBridgeBot for every configured repository\n            List<Bot> mailingListBridgeBots = bots.stream().filter(e -> e.getClass().equals(MailingListBridgeBot.class)).toList();\n\n            assertEquals(2, mailingListArchiveReaderBots.size());\n            assertEquals(3, mailingListBridgeBots.size());\n\n            MailingListArchiveReaderBot mailingListArchiveReaderBot1 =\n                    (MailingListArchiveReaderBot) mailingListArchiveReaderBots.get(0);\n            assertEquals(\"MailingListArchiveReaderBot@repo3\", mailingListArchiveReaderBot1.toString());\n            MailingListReader readerBot1MailingListReader = mailingListArchiveReaderBot1.mailingListReader();\n            assertTrue(readerBot1MailingListReader.getClass().getName().contains(\"Mailman2\"),\n                    readerBot1MailingListReader.getClass().getName());\n\n            MailingListArchiveReaderBot mailingListArchiveReaderBot2 =\n                    (MailingListArchiveReaderBot) mailingListArchiveReaderBots.get(1);\n            assertEquals(\"MailingListArchiveReaderBot@repo5\", mailingListArchiveReaderBot2.toString());\n            MailingListReader readerBot2MailingListReader = mailingListArchiveReaderBot2.mailingListReader();\n            assertTrue(readerBot2MailingListReader.getClass().getName().contains(\"Mailman2\"),\n                    readerBot2MailingListReader.getClass().getName());\n\n            MailingListBridgeBot mailingListBridgeBot1 = (MailingListBridgeBot) mailingListBridgeBots.get(0);\n            assertEquals(\"MailingListBridgeBot@repo3\", mailingListBridgeBot1.toString());\n            assertEquals(\"repo3\", mailingListBridgeBot1.codeRepo().name());\n            assertEquals(\"archive\", mailingListBridgeBot1.archiveRepo().name());\n            assertEquals(\"master\", mailingListBridgeBot1.archiveRef());\n            assertEquals(\"master\", mailingListBridgeBot1.censusRef());\n            assertEquals(\"census\", mailingListBridgeBot1.censusRepo().name());\n            assertEquals(\"<test_email1@test.org>\", mailingListBridgeBot1.lists().get(0).list().toString());\n            assertEquals(\"[ignore1[bot], ignore2[bot], ignore4[bot], ignore3[bot]]\", mailingListBridgeBot1.ignoredUsers().toString());\n            assertEquals(\"[<!-- It's a test comment!-->]\", mailingListBridgeBot1.ignoredComments().toString());\n            assertEquals(\"[rfr]\", mailingListBridgeBot1.readyLabels().toString());\n            assertEquals(\"{test_user[bot]=<!-- Welcome message -->}\", mailingListBridgeBot1.readyComments().toString());\n            assertEquals(\"{Approved=test}\", mailingListBridgeBot1.headers().toString());\n            assertEquals(\"https://bugs.test.org/browse/\", mailingListBridgeBot1.issueTracker().toString());\n            assertEquals(Duration.ofMinutes(2), mailingListBridgeBot1.cooldown());\n            assertFalse(mailingListBridgeBot1.repoInSubject());\n            assertEquals(\"dev\", mailingListBridgeBot1.branchInSubject().toString());\n            MailingListServer bridgeBot1MailingListServer = mailingListBridgeBot1.mailingListServer();\n            assertTrue(bridgeBot1MailingListServer.getClass().getName().contains(\"Mailman2\"));\n\n            MailingListBridgeBot mailingListBridgeBot2 = (MailingListBridgeBot) mailingListBridgeBots.get(1);\n            assertEquals(\"MailingListBridgeBot@repo4\", mailingListBridgeBot2.toString());\n            assertEquals(\"repo4\", mailingListBridgeBot2.codeRepo().name());\n            assertEquals(\"archive\", mailingListBridgeBot2.archiveRepo().name());\n            assertEquals(\"master\", mailingListBridgeBot2.archiveRef());\n            assertEquals(\"master\", mailingListBridgeBot2.censusRef());\n            assertEquals(\"census\", mailingListBridgeBot2.censusRepo().name());\n            assertEquals(\"<test_email2@test.com>\", mailingListBridgeBot2.lists().get(0).list().toString());\n            assertEquals(\"[ignore1[bot], ignore2[bot], ignore4[bot], ignore3[bot]]\", mailingListBridgeBot2.ignoredUsers().toString());\n            assertEquals(\"[<!-- It's a test comment!-->]\", mailingListBridgeBot2.ignoredComments().toString());\n            assertEquals(\"[rfr]\", mailingListBridgeBot2.readyLabels().toString());\n            assertEquals(\"{test_user[bot]=<!-- Welcome message -->}\", mailingListBridgeBot2.readyComments().toString());\n            assertEquals(0, mailingListBridgeBot2.headers().size());\n            assertEquals(\"https://bugs.test.org/browse/\", mailingListBridgeBot2.issueTracker().toString());\n            assertEquals(Duration.ofMinutes(2), mailingListBridgeBot2.cooldown());\n            assertTrue(mailingListBridgeBot2.repoInSubject());\n            MailingListServer bridgeBot2MailingListServer = mailingListBridgeBot2.mailingListServer();\n            assertTrue(bridgeBot2MailingListServer.getClass().getName().contains(\"Mailman2\"));\n\n            MailingListBridgeBot mailingListBridgeBot3 = (MailingListBridgeBot) mailingListBridgeBots.get(2);\n            assertEquals(\"MailingListBridgeBot@repo5\", mailingListBridgeBot3.toString());\n            assertEquals(\"repo5\", mailingListBridgeBot3.codeRepo().name());\n            assertEquals(\"archive\", mailingListBridgeBot3.archiveRepo().name());\n            assertEquals(\"master\", mailingListBridgeBot3.archiveRef());\n            assertEquals(\"master\", mailingListBridgeBot3.censusRef());\n            assertEquals(\"census\", mailingListBridgeBot3.censusRepo().name());\n            assertEquals(\"<test_email3@test.org>\", mailingListBridgeBot3.lists().get(0).list().toString());\n            assertEquals(\"[label1, label2, label3]\", mailingListBridgeBot3.lists().get(0).labels().toString());\n            assertEquals(\"[ignore1[bot], ignore2[bot], ignore4[bot], ignore3[bot]]\", mailingListBridgeBot3.ignoredUsers().toString());\n            assertEquals(\"[<!-- It's a test comment!-->]\", mailingListBridgeBot3.ignoredComments().toString());\n            assertEquals(\"[rfr]\", mailingListBridgeBot3.readyLabels().toString());\n            assertEquals(\"{test_user[bot]=<!-- Welcome message -->}\", mailingListBridgeBot3.readyComments().toString());\n            assertEquals(\"{Approved=test5}\", mailingListBridgeBot3.headers().toString());\n            assertEquals(\"https://test.test.com/issueProject\", mailingListBridgeBot3.issueTracker().toString());\n            assertEquals(Duration.ofMinutes(2), mailingListBridgeBot3.cooldown());\n            assertTrue(mailingListBridgeBot3.repoInSubject());\n            assertEquals(\"master\", mailingListBridgeBot3.branchInSubject().toString());\n            MailingListServer bridgeBot3MailingListServer = mailingListBridgeBot3.mailingListServer();\n            assertTrue(bridgeBot3MailingListServer.getClass().getName().contains(\"Mailman2\"));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/test/java/org/openjdk/skara/bots/mlbridge/MailingListBridgeBotTests.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.bots.common.PullRequestConstants;\nimport org.openjdk.skara.email.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.mailinglist.*;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.Repository;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.time.*;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass MailingListBridgeBotTests {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.mlbridge.test\");\n\n    private Optional<String> archiveContents(Path archive, String prId) {\n        try (var paths = Files.find(archive, 50, (path, attrs) -> path.toString().endsWith(\".mbox\"))) {\n            var mbox = paths.filter(path -> path.getFileName().toString().contains(prId))\n                            .findAny();\n            if (mbox.isEmpty()) {\n                return Optional.empty();\n            }\n            return Optional.of(Files.readString(mbox.get()));\n        } catch (IOException e) {\n            return Optional.empty();\n        }\n\n    }\n\n    private boolean archiveContains(Path archive, String text) {\n        return archiveContains(archive, text, \"\");\n    }\n\n    private boolean archiveContains(Path archive, String text, String prId) {\n        return archiveContainsCount(archive, text, prId) > 0;\n    }\n\n    private int archiveContainsCount(Path archive, String text) {\n        return archiveContainsCount(archive, text, \"\");\n    }\n\n    private int archiveContainsCount(Path archive, String text, String prId) {\n        var lines = archiveContents(archive, prId);\n        if (lines.isEmpty()) {\n            return 0;\n        }\n        var pattern = Pattern.compile(text);\n        int count = 0;\n        for (var line : lines.get().split(\"\\\\R\")) {\n            var matcher = pattern.matcher(line);\n            if (matcher.find()) {\n                count++;\n            }\n        }\n        return count;\n    }\n\n    private boolean webrevContains(Path webrev, String text) {\n        try (var paths = Files.find(webrev, 5, (path, attrs) -> path.toString().endsWith(\"index.html\"))) {\n            var index = paths.findAny();\n            if (index.isEmpty()) {\n                return false;\n            }\n            var lines = Files.readString(index.get());\n            return lines.contains(text);\n        } catch (IOException e) {\n            return false;\n        }\n    }\n\n    private long countSubstrings(String string, String substring) {\n        return Pattern.compile(substring).matcher(string).results().count();\n    }\n\n    @Test\n    void simpleArchive(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(), listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                                            .ignoredComments(Set.of())\n                                            .mailingListServer(mailmanServer)\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .readyLabels(Set.of(\"rfr\"))\n                                            .readyComments(Map.of(ignored.forge().currentUser().username(), Pattern.compile(\"ready\")))\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .headers(Map.of(\"Extra1\", \"val1\", \"Extra2\", \"val2\"))\n                                            .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n            pr.setBody(\"This should not be ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // A PR that isn't ready for review should not be archived\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertFalse(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n\n            // Flag it as ready for review\n            pr.setBody(\"This should now be ready\");\n            pr.addLabel(\"rfr\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // But it should still not be archived\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertFalse(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n\n            // Now post a general comment - not a ready marker\n            var ignoredPr = ignored.pullRequest(pr.id());\n            ignoredPr.addComment(\"hello there\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // It should still not be archived\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertFalse(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n\n            //skara command prefixed with non-white space - should be archived\n            pr.addComment(\"do not ignore me /help\");\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            //valid skara command - should not be archived\n            pr.addComment(\"/skara   help\");\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            //Invalid skara command but starting with '/' - should be archived\n            pr.addComment(\"/skara some-text & more text\");\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            //Not a valid skara command with upper case letter - should be archived\n            pr.addComment(\"/skara Help\");\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // Now post a ready comment\n            ignoredPr.addComment(\"ready\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain an entry\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"This should now be ready\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Patch:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Changes:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Webrev:\"));\n            assertTrue(archiveContains(archiveFolder.path(), webrevServer.uri().toString()));\n            assertTrue(archiveContains(archiveFolder.path(), pr.id() + \"/00\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Issue:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"http://issues.test/browse/TSTPRJ-1234\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Fetch:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"^ - Change msg\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"With several lines\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"do not ignore me /help\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"Available commands\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"/skara some-text & more text\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"/skara Help\"));\n\n            // The mailing list as well\n            listServer.processIncoming();\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var mail = conversations.get(0).first();\n            assertEquals(\"RFR: 1234: This is a pull request\", mail.subject());\n            assertEquals(pr.author().fullName(), mail.author().fullName().orElseThrow());\n            assertEquals(from.address(), mail.author().address());\n            assertEquals(listAddress, mail.sender());\n            assertEquals(\"val1\", mail.headerValue(\"Extra1\"));\n            assertEquals(\"val2\", mail.headerValue(\"Extra2\"));\n\n            // And there should be a webrev\n            Repository.materialize(webrevFolder.path(), archive.authenticatedUrl(), \"webrev\");\n            assertTrue(webrevContains(webrevFolder.path(), \"1 lines changed\"));\n            var comments = pr.comments();\n            var webrevComments = comments.stream()\n                                         .filter(comment -> comment.author().equals(author.forge().currentUser()))\n                                         .filter(comment -> comment.body().contains(\"webrev\"))\n                                         .filter(comment -> comment.body().contains(editHash.hex()))\n                                         .collect(Collectors.toList());\n            assertEquals(1, webrevComments.size());\n\n            // Add a comment\n            pr.addComment(\"This is a comment :smile:\");\n\n            // Add a comment from an ignored user as well\n            ignoredPr.addComment(\"Don't mind me\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain the comment, but not the ignored one\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"This is a comment\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"> This should now be ready\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"hosted.git/pr/1/comment/3\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"Don't mind me\"));\n\n            listServer.processIncoming();\n            conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            assertEquals(2, conversations.get(0).allMessages().size());\n            assertTrue(conversations.get(0).allMessages().get(1).body().contains(\"hosted.git/pr/1/comment/3\"));\n\n            // Remove the rfr flag and post another comment\n            pr.removeLabel(\"rfr\");\n            pr.addComment(\"@\" + pr.author().username() +\" This is another comment\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should contain the additional comment\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"This is another comment\"));\n            assertTrue(archiveContains(archiveFolder.path(), \">> This should now be ready\"));\n\n            listServer.processIncoming();\n            listServer.processIncoming();\n            conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            assertEquals(4, conversations.get(0).allMessages().size());\n            for (var newMail : conversations.get(0).allMessages()) {\n                assertEquals(from.address(), newMail.author().address());\n                assertEquals(listAddress, newMail.sender());\n            }\n            assertTrue(conversations.get(0).allMessages().get(3).body().contains(\"This is another comment\"));\n            assertTrue(conversations.get(0).allMessages().get(3).body().contains(\"hosted.git/pr/1/comment/9\"));\n        }\n    }\n\n    @Test\n    void archiveIntegrated(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n            pr.setBody(\"This is now ready\");\n            pr.addLabel(\"rfr\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // There should be an RFR thread\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: RFR: 1234: This is a pull request\"));\n\n            // Add a comment quickly before integration - it should not be combined with the integration message\n            pr.addComment(\"I will now integrate this PR\");\n\n            // Add sponsor label and comment\n            var ignoredPr = ignored.pullRequest(pr.id());\n            ignoredPr.addLabel(\"sponsor\");\n            ignoredPr.addComment(String.format(PullRequestConstants.READY_FOR_SPONSOR_MARKER, editHash.hex()) +\n                    \"@\" + author.name() + \" Your change (at version \" + editHash.abbreviate() + \") is now ready to be sponsored by a Committer.\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"is now ready to be sponsored by a Committer.\"));\n\n            // Mark it as integrated but skip adding the integration comment for now\n            ignoredPr.setBody(\"This has been integrated\");\n            ignoredPr.addLabel(\"integrated\");\n            ignoredPr.removeLabel(\"sponsor\");\n            ignoredPr.removeLabel(\"rfr\");\n            ignoredPr.removeLabel(\"ready\");\n            ignoredPr.setState(Issue.State.CLOSED);\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // Verify that no integration message was added to the archive\n            assertFalse(archiveContains(archiveFolder.path(), \"Subject: Integrated:\"));\n\n            // Add the integration comment\n            ignoredPr.addComment(\"Pushed as commit \" + editHash + \".\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain another entry\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: Re: RFR: 1234: This is a pull request\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: Integrated: 1234: This is a pull request\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"\\\\[Closed\\\\]\"));\n        }\n    }\n\n    @Test\n    void archiveLegacyIntegrated(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR with a date in the past\n            var editFile = tempFolder.path().resolve(\"change.txt\");\n            Files.writeString(editFile, \"A simple change\");\n            localRepo.add(editFile);\n            var commitDate = ZonedDateTime.of(2020, 3, 12, 0, 0, 0, 0, ZoneId.of(\"UTC\"));\n            var editHash = localRepo.commit(\"An old change\", \"duke\", \"duke@openjdk.org\", commitDate,\n                             \"duke\", \"duke@openjdk.org\", commitDate);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n            pr.setBody(\"This is now ready\");\n            pr.addLabel(\"rfr\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // There should be an RFR thread\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: RFR: 1234: This is a pull request\"));\n\n            // Now it has been integrated\n            var ignoredPr = ignored.pullRequest(pr.id());\n            ignoredPr.setBody(\"This has been integrated\");\n            ignoredPr.addLabel(\"integrated\");\n            ignoredPr.addComment(\"Pushed as commit \" + editHash + \".\");\n            ignoredPr.setState(Issue.State.CLOSED);\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should not contain another entry\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertFalse(archiveContains(archiveFolder.path(), \"\\\\[Integrated\\\\]\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"\\\\[Closed\\\\]\"));\n        }\n    }\n\n    @Test\n    void archiveDirectToIntegrated(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n            pr.setBody(\"This should not be ready\");\n            pr.setState(Issue.State.CLOSED);\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // A PR that isn't ready for review should not be archived\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertFalse(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n\n            // Now it has been integrated\n            var ignoredPr = ignored.pullRequest(pr.id());\n            ignoredPr.setBody(\"This has already been integrated\");\n            ignoredPr.addLabel(\"integrated\");\n            ignoredPr.addComment(\"Pushed as commit \" + editHash + \".\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain an entry\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: Integrated: 1234: This is a pull request\"));\n\n            var updatedHash = CheckableRepository.appendAndCommit(localRepo, \"Another change\");\n            localRepo.push(updatedHash, author.authenticatedUrl(), \"edit\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain another entry\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: Re: Integrated: 1234: This is a pull request \\\\[v2\\\\]\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"Withdrawn\"));\n        }\n    }\n\n    @Test\n    void archiveIntegratedRetainPrefix(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .readyLabels(Set.of(\"rfr\"))\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n            pr.setBody(\"This should be ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // A PR that isn't ready for review should not be archived\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertFalse(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n\n            // Flag it as ready for review\n            pr.addLabel(\"rfr\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain an entry\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: RFR: 1234: This is a pull request\"));\n\n            // Close it and then push another change\n            pr.setState(Issue.State.CLOSED);\n            var updatedHash = CheckableRepository.appendAndCommit(localRepo, \"Another change\");\n            localRepo.push(updatedHash, author.authenticatedUrl(), \"edit\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain another entry - should retain the RFR thread prefix\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: Re: RFR: 1234: This is a pull request \\\\[v2\\\\]\"));\n        }\n    }\n\n    @Test\n    void archiveClosed(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .readyLabels(Set.of(\"rfr\"))\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n            pr.setBody(\"This should be ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // A PR that isn't ready for review should not be archived\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertFalse(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n\n            // Flag it as ready for review\n            pr.addLabel(\"rfr\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain an entry\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: RFR: 1234: This is a pull request\"));\n\n            // Close it (as a separate user)\n            var closerPr = ignored.pullRequest(pr.id());\n            closerPr.setState(Issue.State.CLOSED);\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain another entry - should say that it is closed\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"Subject: Withdrawn: 1234: This is a pull request\"));\n\n            pr.addComment(\"Fair enough\");\n\n            // Run another archive pass - only a single close notice should have been posted\n            TestBotRunner.runPeriodicItems(mlBot);\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"Subject: Withdrawn: 1234: This is a pull request\"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"Subject: Re: RFR: 1234: This is a pull request\"));\n\n            // The closer should be the bot account - not the PR creator nor the closer\n            assertEquals(2, archiveContainsCount(archiveFolder.path(), Pattern.quote(\"From: test at test.mail (User Number 2)\")));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), Pattern.quote(\"From: test at test.mail (test)\")));\n            assertEquals(0, archiveContainsCount(archiveFolder.path(), Pattern.quote(\"From: test at test.mail (User Number 3)\")));\n        }\n    }\n\n    @Test\n    void archiveFailedAutoMerge(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"Cannot automatically merge\");\n            pr.setBody(\"This is an automated merge PR\");\n            pr.addLabel(\"failed-auto-merge\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should contain an entry\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: RFR: Cannot automatically merge\"));\n        }\n    }\n\n    @Test\n    void reviewComment(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // And make a file specific comment\n            var currentMaster = localRepo.resolve(\"master\").orElseThrow();\n            var comment = pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, \"Review comment\");\n\n            // Add one from an ignored user as well\n            var ignoredPr = ignored.pullRequest(pr.id());\n            ignoredPr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, \"Don't mind me\");\n\n            // Process comments\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The archive should now contain an entry\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"This is now ready\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Review comment\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"> This is now ready\"));\n            assertTrue(archiveContains(archiveFolder.path(), reviewFile + \" line 2:\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"Don't mind me\"));\n\n            // The mailing list as well\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var mail = conversations.get(0).first();\n            assertEquals(\"RFR: This is a pull request\", mail.subject());\n\n            // Comment on the comment\n            pr.addReviewCommentReply(comment, \"This is a review reply\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The archive should contain the additional comment (but no quoted footers)\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"This is a review reply\"));\n            assertTrue(archiveContains(archiveFolder.path(), \">> This is now ready\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"^> PR:\"));\n\n            // As well as the mailing list\n            conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            assertEquals(3, conversations.get(0).allMessages().size());\n            assertTrue(conversations.get(0).allMessages().get(1).body().contains(\"hosted.git/pr/1/reviewComment/0\"));\n            assertTrue(conversations.get(0).allMessages().get(2).body().contains(\"hosted.git/pr/1/reviewComment/2\"));\n\n            for (var newMail : conversations.get(0).allMessages()) {\n                assertEquals(from.address(), newMail.author().address());\n                assertEquals(listAddress, newMail.sender());\n            }\n\n            // Add a file comment (on line 0)\n            var fileComment = pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 0, \"File review comment\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The archive should contain the additional comment\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"File review comment\"));\n            assertTrue(archiveContains(archiveFolder.path(), reviewFile + \":\"));\n        }\n    }\n\n    @Test\n    void combineComments(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n            pr.addComment(\"Avoid combining\");\n\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n            listServer.processIncoming();\n\n            // Make several file specific comments\n            var first = pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, \"Review comment\");\n            var second = pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, \"Another review comment\");\n            pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, \"Further review comment\");\n            pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, \"Final review comment\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The archive should contain a combined entry\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertEquals(2, archiveContainsCount(archiveFolder.path(), \"^On.*wrote:\"));\n\n            // As well as the mailing list\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var mail = conversations.get(0).first();\n            assertEquals(\"RFR: This is a pull request\", mail.subject());\n            assertEquals(3, conversations.get(0).allMessages().size());\n\n            var commentReply = conversations.get(0).replies(mail).get(0);\n            assertEquals(2, commentReply.body().split(\"^On.*wrote:\").length);\n            assertTrue(commentReply.body().contains(\"Avoid combining\\n\\n\"), commentReply.body());\n\n            var reviewReply = conversations.get(0).replies(mail).get(1);\n            assertEquals(2, reviewReply.body().split(\"^On.*wrote:\").length);\n            assertEquals(2, reviewReply.body().split(\"> This is now ready\").length, reviewReply.body());\n            assertEquals(\"RFR: This is a pull request\", reviewReply.subject());\n            assertTrue(reviewReply.body().contains(\"Review comment\\n\\n\"), reviewReply.body());\n            assertTrue(reviewReply.body().contains(\"Another review comment\"), reviewReply.body());\n            assertTrue(reviewReply.body().contains(\"hosted.git/pr/1/reviewComment/0\"), reviewReply.body());\n            assertTrue(reviewReply.body().contains(\"hosted.git/pr/1/reviewComment/1\"), reviewReply.body());\n            assertTrue(reviewReply.body().contains(\"hosted.git/pr/1/reviewComment/2\"), reviewReply.body());\n            assertTrue(reviewReply.body().contains(\"hosted.git/pr/1/reviewComment/3\"), reviewReply.body());\n\n            // Now reply to the first and second (collapsed) comment\n            pr.addReviewCommentReply(first, \"I agree\");\n            pr.addReviewCommentReply(second, \"Not with this one though\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The archive should contain a new entry\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertEquals(3, archiveContainsCount(archiveFolder.path(), \"^On.*wrote:\"));\n\n            // The combined review comments should only appear unquoted once\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"^Another review comment\"));\n        }\n    }\n\n    @Test\n    void commentThreading(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Make a file specific comment\n            var reviewPr = reviewer.pullRequest(pr.id());\n            var comment1 = reviewPr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, \"Review comment\");\n            pr.addReviewCommentReply(comment1, \"I agree\");\n            reviewPr.addReviewCommentReply(comment1, \"Great\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n            listServer.processIncoming();\n            listServer.processIncoming();\n\n            // And a second one by ourselves\n            var comment2 = pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, \"Another review comment\");\n            reviewPr.addReviewCommentReply(comment2, \"Sounds good\");\n            pr.addReviewCommentReply(comment2, \"Thanks\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n            listServer.processIncoming();\n            listServer.processIncoming();\n\n            // Finally some approvals and another comment\n            pr.addReview(Review.Verdict.APPROVED, \"Nice\");\n            reviewPr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, \"The final review comment\");\n            reviewPr.addReview(Review.Verdict.APPROVED, \"Looks fine\");\n            reviewPr.addReviewCommentReply(comment2, \"You are welcome\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n            listServer.processIncoming();\n            listServer.processIncoming();\n\n            // Sanity check the archive\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertEquals(9, archiveContainsCount(archiveFolder.path(), \"^On.*wrote:\"));\n\n            // File specific comments should appear after the approval\n            var archiveText = archiveContents(archiveFolder.path(), \"\").orElseThrow();\n            assertTrue(archiveText.indexOf(\"Looks fine\") < archiveText.indexOf(\"The final review comment\"));\n\n            // Check the mailing list\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var mail = conversations.get(0).first();\n            assertEquals(\"RFR: This is a pull request\", mail.subject());\n            assertEquals(10, conversations.get(0).allMessages().size());\n\n            // There should be four separate threads\n            var thread1 = conversations.get(0).replies(mail).get(0);\n            assertEquals(2, thread1.body().split(\"^On.*wrote:\").length);\n            assertEquals(2, thread1.body().split(\"> This is now ready\").length, thread1.body());\n            assertEquals(\"RFR: This is a pull request\", thread1.subject());\n            assertTrue(thread1.body().contains(\"Review comment\\n\\n\"), thread1.body());\n            assertFalse(thread1.body().contains(\"Another review comment\"), thread1.body());\n            var thread1reply1 = conversations.get(0).replies(thread1).get(0);\n            assertTrue(thread1reply1.body().contains(\"I agree\"));\n            assertEquals(from.address(), thread1reply1.author().address());\n            assertEquals(archive.forge().currentUser().fullName(), thread1reply1.author().fullName().orElseThrow());\n            var thread1reply2 = conversations.get(0).replies(thread1reply1).get(0);\n            assertTrue(thread1reply2.body().contains(\"Great\"));\n            assertEquals(\"integrationreviewer1@openjdk.org\", thread1reply2.author().address());\n            assertEquals(\"Generated Reviewer 1\", thread1reply2.author().fullName().orElseThrow());\n\n            var thread2 = conversations.get(0).replies(mail).get(1);\n            assertEquals(2, thread2.body().split(\"^On.*wrote:\").length);\n            assertEquals(2, thread2.body().split(\"> This is now ready\").length, thread2.body());\n            assertEquals(\"RFR: This is a pull request\", thread2.subject());\n            assertFalse(thread2.body().contains(\"Review comment\\n\\n\"), thread2.body());\n            assertTrue(thread2.body().contains(\"Another review comment\"), thread2.body());\n            var thread2reply1 = conversations.get(0).replies(thread2).get(0);\n            assertTrue(thread2reply1.body().contains(\"Sounds good\"));\n            var thread2reply2 = conversations.get(0).replies(thread2reply1).get(0);\n            assertTrue(thread2reply2.body().contains(\"Thanks\"));\n\n            var replies = conversations.get(0).replies(mail);\n            var thread3 = replies.get(2);\n            assertEquals(\"RFR: This is a pull request\", thread3.subject());\n            var thread4 = replies.get(3);\n            assertEquals(\"RFR: This is a pull request\", thread4.subject());\n            assertTrue(thread4.body().contains(\"Looks fine\"));\n            assertTrue(thread4.body().contains(\"The final review comment\"));\n            assertTrue(thread4.body().contains(\"Marked as reviewed by integrationreviewer1 (Reviewer)\"));\n        }\n    }\n\n    @Test\n    void commentThreadingSeparated(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile1 = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile1);\n            var reviewFile2 = Path.of(\"aardvark_reviewfile.txt\");\n            Files.writeString(localRepo.root().resolve(reviewFile2), \"1\\n2\\n3\\n4\\n5\\n6\\n\");\n            localRepo.add(reviewFile2);\n            var masterHash = localRepo.commit(\"Another one\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Make a few file specific comments\n            var reviewPr = reviewer.pullRequest(pr.id());\n            var comment1 = reviewPr.addReviewComment(masterHash, editHash, reviewFile1.toString(), 2, \"Review comment\");\n            var comment2 = reviewPr.addReviewComment(masterHash, editHash, reviewFile1.toString(), 3, \"Another review comment\");\n            var comment3 = reviewPr.addReviewComment(masterHash, editHash, reviewFile2.toString(), 4, \"Yet another review comment\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            pr.addReviewCommentReply(comment3, \"I don't care\");\n            pr.addReviewCommentReply(comment2, \"I don't agree\");\n            pr.addReviewCommentReply(comment1, \"I agree\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Sanity check the archive\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertEquals(2, archiveContainsCount(archiveFolder.path(), \"^On.*wrote:\"));\n\n            var archiveText = archiveContents(archiveFolder.path(), \"\").orElseThrow();\n            assertTrue(archiveText.indexOf(\"I agree\") < archiveText.indexOf(\"I don't agree\"), archiveText);\n            assertTrue(archiveText.indexOf(\"I don't care\") < archiveText.indexOf(\"I don't agree\"), archiveText);\n        }\n    }\n\n    @Test\n    void commentWithMention(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Make two comments from different authors\n            var reviewPr = reviewer.pullRequest(pr.id());\n            reviewPr.addComment(\"First comment\");\n            pr.addComment(\"Second comment\");\n\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            pr.addComment(\"@\" + reviewer.forge().currentUser().username() + \" reply to first\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The first comment should be quoted more often than the second\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertEquals(2, archiveContainsCount(archiveFolder.path(), \"First comment\"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"Second comment\"));\n        }\n    }\n\n    @Test\n    void reviewCommentWithMention(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Make two review comments from different authors\n            var reviewPr = reviewer.pullRequest(pr.id());\n            var comment = reviewPr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, \"Review comment\");\n            reviewPr.addReviewCommentReply(comment, \"First review comment\");\n            pr.addReviewCommentReply(comment, \"Second review comment\");\n\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            pr.addReviewCommentReply(comment, \"@\" + reviewer.forge().currentUser().username() + \" reply to first\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The first comment should be quoted more often than the second\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertEquals(3, archiveContainsCount(archiveFolder.path(), \"First review comment\"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"Second review comment\"));\n        }\n    }\n\n    @Test\n    void commentWithQuote(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Make two comments from different authors\n            var reviewPr = reviewer.pullRequest(pr.id());\n            reviewPr.addComment(\"First comment\\nsecond line\");\n            var authorPr = author.pullRequest(pr.id());\n            authorPr.addComment(\"Second comment\\nfourth line\");\n\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            pr.addComment(\">First comm\\n\\nreply to first\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The first comment should be replied to once, and the original post once\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), Pattern.quote(reviewPr.author().fullName()) + \".* wrote\"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), Pattern.quote(pr.author().fullName()) + \".* wrote\"));\n        }\n    }\n\n    @Test\n    void reviewContext(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Line 1\\nLine 2\\nLine 3\\nLine 4\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Make a file specific comment\n            pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, \"Review comment\");\n\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The archive should only contain context up to and including Line 2\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"^> 2: Line 1$\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"^> 3: Line 2$\"));\n        }\n    }\n\n    @Test\n    void multipleReviewContexts(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n            var initialHash = CheckableRepository.appendAndCommit(localRepo,\n                                                                  \"Line 0.1\\nLine 0.2\\nLine 0.3\\nLine 0.4\\n\" +\n                                                                          \"Line 1\\nLine 2\\nLine 3\\nLine 4\\n\" +\n                                                                          \"Line 5\\nLine 6\\nLine 7\\nLine 8\\n\" +\n                                                                          \"Line 8.1\\nLine 8.2\\nLine 8.3\\nLine 8.4\\n\" +\n                                                                          \"Line 9\\nLine 10\\nLine 11\\nLine 12\\n\" +\n                                                                          \"Line 13\\nLine 14\\nLine 15\\nLine 16\\n\");\n            localRepo.push(initialHash, author.authenticatedUrl(), \"master\");\n\n            // Make a change with a corresponding PR\n            var current = Files.readString(localRepo.root().resolve(reviewFile));\n            var updated = current.replaceAll(\"Line 2\", \"Line 2 edit\\nLine 2.5\");\n            updated = updated.replaceAll(\"Line 13\", \"Line 12.5\\nLine 13 edit\");\n            Files.writeString(localRepo.root().resolve(reviewFile), updated);\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Make file specific comments\n            pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 7, \"Review comment\");\n            pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 24, \"Another review comment\");\n\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The archive should only contain context around line 2 and 20\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"reviewfile.txt line 7\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"^> 6: Line 1$\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"^> 7: Line 2 edit$\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"Line 3\"));\n\n            assertTrue(archiveContains(archiveFolder.path(), \"reviewfile.txt line 24\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"^> 23: Line 12.5$\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"^> 24: Line 13 edit$\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"^Line 15\"));\n        }\n    }\n\n    @Test\n    void filterComments(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\\n<!-- this is a comment -->\\nAnd this is not\\n\" +\n                               \"<!-- Anything below this marker will be hidden -->\\nStatus stuff\");\n\n            // Make a bunch of comments\n            pr.addComment(\"Plain comment\\n<!-- this is a comment -->\");\n            pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, \"Review comment <!-- this is a comment -->\\n\");\n            pr.addComment(\"  /cc others\");\n            pr.addComment(\"/integrate stuff\");\n            pr.addComment(\"the command is /hello there\");\n            pr.addComment(\"but this will be parsed\\n/newline command\");\n            pr.addComment(\"/summary\\nwill be dropped\");\n            pr.addComment(\"/csr needed\\nThis should not be dropped\");\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should not contain the comment\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"This is now ready\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"this is a comment\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"Status stuff\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"And this is not\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"<!--\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"-->\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Plain comment\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Review comment\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"/integrate\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"/cc\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"/hello there\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"but this will be parsed\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"/newline\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"/multiline\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"will be dropped\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"This should not be dropped\"));\n\n            // There should not be consecutive empty lines due to a filtered multiline message\n            var lines = archiveContents(archiveFolder.path(), \"\").orElseThrow();\n            assertFalse(lines.contains(\"\\n\\n\\n\"), lines);\n\n            // And a stand-alone multiline comment should not cause another mail to be sent\n            pr.addComment(\"/summary\\nmultiline\\nwill not cause another mail\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            lines = archiveContents(archiveFolder.path(), \"\").orElseThrow();\n            var mails = Mbox.splitMbox(lines, EmailAddress.from(\"duke@openjdk.org\"));\n            assertEquals(2, mails.size());\n        }\n    }\n\n    @Test\n    void incrementalChanges(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var commenter = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            var nextHash = CheckableRepository.appendAndCommit(localRepo, \"Yet one more line\", \"Fixing\");\n            localRepo.push(nextHash, author.authenticatedUrl(), \"edit\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The archive should reference the updated push\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"has updated the pull request incrementally\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"full.*/\" + pr.id() + \"/01\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"inc.*/\" + pr.id() + \"/00-01\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Patch\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Fetch\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Fixing\"));\n\n            // The webrev comment should be updated\n            var comments = pr.comments();\n            var webrevComments = comments.stream()\n                                         .filter(comment -> comment.author().equals(author.forge().currentUser()))\n                                         .filter(comment -> comment.body().contains(\"webrev\"))\n                                         .filter(comment -> comment.body().contains(\"Full\"))\n                                         .filter(comment -> comment.body().contains(\"Incremental\"))\n                                         .filter(comment -> comment.body().contains(nextHash.hex()))\n                                         .filter(comment -> comment.body().contains(editHash.hex()))\n                                         .collect(Collectors.toList());\n            assertEquals(1, webrevComments.size());\n\n            // Check that sender address is set properly\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            for (var newMail : conversations.get(0).allMessages()) {\n                assertEquals(from.address(), newMail.author().address());\n                assertEquals(listAddress, newMail.sender());\n            }\n\n            // Add a comment\n            var commenterPr = commenter.pullRequest(pr.id());\n            commenterPr.addReviewComment(masterHash, nextHash, reviewFile.toString(), 2, \"Review comment\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Ensure that additional updates are only reported once\n            for (int i = 0; i < 3; ++i) {\n                var anotherHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"Fixing\");\n                localRepo.push(anotherHash, author.authenticatedUrl(), \"edit\");\n\n                TestBotRunner.runPeriodicItems(mlBot);\n                TestBotRunner.runPeriodicItems(mlBot);\n                listServer.processIncoming();\n            }\n            var updatedConversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, updatedConversations.size());\n            var conversation = updatedConversations.get(0);\n            assertEquals(6, conversation.allMessages().size());\n            assertEquals(\"RFR: This is a pull request [v2]\", conversation.allMessages().get(1).subject());\n            assertEquals(\"RFR: This is a pull request [v2]\", conversation.allMessages().get(2).subject(), conversation.allMessages().get(2).toString());\n            assertEquals(\"RFR: This is a pull request [v5]\", conversation.allMessages().get(5).subject());\n        }\n    }\n\n    @Test\n    void forcePushed(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository(\"author\");\n            var main = credentials.getHostedRepository(\"main\");\n            var archive = credentials.getHostedRepository(\"archive\");\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var sender = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(sender)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path().resolve(\"first\"), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, main.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A line\", \"Original msg\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, main, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            var newLocalRepo = Repository.materialize(tempFolder.path().resolve(\"second\"), author.authenticatedUrl(), \"master\");\n            var newEditHash = CheckableRepository.appendAndCommit(newLocalRepo, \"Another line\", \"Replaced msg\");\n            newLocalRepo.push(newEditHash, author.authenticatedUrl(), \"edit\", true);\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The archive should reference the rebased push\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"has refreshed the contents of this pull request, and previous commits have been removed.\"));\n            assertTrue(archiveContains(archiveFolder.path(), pr.id() + \"/01\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Patch\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Fetch\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Original msg\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Replaced msg\"));\n\n            // The webrev comment should be updated\n            var comments = pr.comments();\n            var webrevComments = comments.stream()\n                                         .filter(comment -> comment.author().equals(author.forge().currentUser()))\n                                         .filter(comment -> comment.body().contains(\"webrev\"))\n                                         .filter(comment -> comment.body().contains(newEditHash.hex()))\n                                         .collect(Collectors.toList());\n            assertEquals(1, webrevComments.size());\n\n            // Check that sender address is set properly\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            for (var newMail : conversations.get(0).allMessages()) {\n                assertEquals(sender.address(), newMail.author().address());\n                assertEquals(listAddress, newMail.sender());\n                assertFalse(newMail.hasHeader(\"PR-Head-Hash\"));\n            }\n            assertEquals(\"RFR: This is a pull request [v2]\", conversations.get(0).allMessages().get(1).subject());\n        }\n    }\n\n    @Test\n    void rebased(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository(\"author\");\n            var main = credentials.getHostedRepository(\"main\");\n            var archive = credentials.getHostedRepository(\"archive\");\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id());\n            var sender = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                    .from(sender)\n                    .repo(author)\n                    .archive(archive)\n                    .censusRepo(censusBuilder.build())\n                    .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                    .webrevStorageHTMLRepository(archive)\n                    .webrevStorageRef(\"webrev\")\n                    .webrevStorageBase(Path.of(\"test\"))\n                    .webrevStorageBaseUri(webrevServer.uri())\n                    .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                    .mailingListServer(mailmanServer)\n                    .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path().resolve(\"first\"), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, main.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Edit line\", \"Original msg\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, main, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Add another change in the master\n            localRepo.checkout(masterHash);\n            var newMasterHash = CheckableRepository.appendAndCommit(localRepo, \"New master line\", \"New master commit message\");\n            localRepo.push(newMasterHash, main.authenticatedUrl(), \"master\");\n            // Add a new \"rebased\" version of the edit change on top of the new master and force\n            // push it to the PR. This should emulate a rebase.\n            localRepo.push(newMasterHash, author.authenticatedUrl(), \"master\");\n            var newEditHash = CheckableRepository.appendAndCommit(localRepo, \"Edit line\", \"New edit commit message\");\n            localRepo.push(newEditHash, author.authenticatedUrl(), \"edit\", true);\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The archive should reference the rebased push\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"has updated the pull request with a new target base\"));\n            assertTrue(archiveContains(archiveFolder.path(), pr.id() + \"/01\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"Incremental\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Patch\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Fetch\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Original msg\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"New edit commit message\"));\n\n            // The webrev comment should be updated\n            var comments = pr.comments();\n            var webrevComments = comments.stream()\n                    .filter(comment -> comment.author().equals(author.forge().currentUser()))\n                    .filter(comment -> comment.body().contains(\"webrev\"))\n                    .filter(comment -> comment.body().contains(newEditHash.hex()))\n                    .collect(Collectors.toList());\n            assertEquals(1, webrevComments.size());\n\n            // Check that sender address is set properly\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            for (var newMail : conversations.get(0).allMessages()) {\n                assertEquals(sender.address(), newMail.author().address());\n                assertEquals(listAddress, newMail.sender());\n                assertFalse(newMail.hasHeader(\"PR-Head-Hash\"));\n            }\n            assertEquals(\"RFR: This is a pull request [v2]\", conversations.get(0).allMessages().get(1).subject());\n        }\n    }\n\n    @Test\n    void incrementalAfterRebase(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var sender = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(sender)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .archiveRef(\"archive\")\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path().resolve(\"first\"), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"archive\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A line\", \"Original msg\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Push more stuff to master\n            localRepo.checkout(masterHash, true);\n            var unrelatedFile = localRepo.root().resolve(\"unrelated.txt\");\n            Files.writeString(unrelatedFile, \"Other things happens in master\");\n            localRepo.add(unrelatedFile);\n            var newMasterHash = localRepo.commit(\"Unrelated change\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(newMasterHash, author.authenticatedUrl(), \"master\");\n\n            // And more stuff to the pr branch\n            localRepo.checkout(editHash, true);\n            CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"More updates\");\n\n            // Merge master\n            localRepo.merge(newMasterHash);\n            var newEditHash = localRepo.commit(\"Latest changes from master\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(newEditHash, author.authenticatedUrl(), \"edit\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The archive should reference the rebased push\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"archive\");\n            assertTrue(archiveContains(archiveFolder.path(), \"has updated the pull request with a new target base\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"excludes\"));\n            assertTrue(archiveContains(archiveFolder.path(), pr.id() + \"/01\"));\n            assertTrue(archiveContains(archiveFolder.path(), pr.id() + \"/00-01\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Original msg\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"More updates\"));\n        }\n    }\n\n    @Test\n    void mergeWebrev(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var commenter = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .archiveRef(\"archive\")\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"archive\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Create a diverging branch\n            var editOnlyFile = Path.of(\"editonly.txt\");\n            Files.writeString(localRepo.root().resolve(editOnlyFile), \"Only added in the edit\");\n            localRepo.add(editOnlyFile);\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Edited\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\");\n\n            // Make conflicting changes in the target\n            localRepo.checkout(masterHash, true);\n            var masterOnlyFile = Path.of(\"masteronly.txt\");\n            Files.writeString(localRepo.root().resolve(masterOnlyFile), \"Only added in master\");\n            localRepo.add(masterOnlyFile);\n            var updatedMasterHash = CheckableRepository.appendAndCommit(localRepo, \"Master change\");\n            localRepo.push(updatedMasterHash, author.authenticatedUrl(), \"master\");\n\n            // Perform the merge - resolve conflicts in our favor\n            localRepo.merge(editHash, \"ours\");\n            localRepo.commit(\"Merged edit\", \"duke\", \"duke@openjdk.org\");\n            var mergeOnlyFile = Path.of(\"mergeonly.txt\");\n            Files.writeString(localRepo.root().resolve(mergeOnlyFile), \"Only added in the merge\");\n            localRepo.add(mergeOnlyFile);\n            Files.writeString(localRepo.root().resolve(reviewFile), \"Overwriting the conflict resolution\");\n            localRepo.add(reviewFile);\n            var appendedCommit = localRepo.amend(\"Updated merge commit\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(appendedCommit, author.authenticatedUrl(), \"merge_of_edit\", true);\n\n            // Make a merge PR\n            var pr = credentials.createPullRequest(archive, \"master\", \"merge_of_edit\", \"Merge edit\");\n            pr.setBody(\"This is now ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The archive should contain a merge style webrev\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"archive\");\n            assertTrue(archiveContains(archiveFolder.path(), \"The webrevs contain the adjustments done while merging with regards to each parent branch:\"));\n            assertTrue(archiveContains(archiveFolder.path(), pr.id() + \"/00.0\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"3 lines in 2 files changed: 1 ins; 1 del; 1 mod\"));\n\n            // The PR should contain a webrev comment\n            assertEquals(1, pr.comments().size());\n            var webrevComment = pr.comments().get(0);\n            assertTrue(webrevComment.body().contains(\"Merge target\"));\n            assertTrue(webrevComment.body().contains(\"Merge source\"));\n        }\n    }\n\n    @Test\n    void mergeWebrevConflict(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var commenter = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .archiveRef(\"archive\")\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"archive\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Create a merge\n            var editOnlyFile = Path.of(\"editonly.txt\");\n            Files.writeString(localRepo.root().resolve(editOnlyFile), \"Only added in the edit\");\n            localRepo.add(editOnlyFile);\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Edited\");\n            localRepo.checkout(masterHash, true);\n            var masterOnlyFile = Path.of(\"masteronly.txt\");\n            Files.writeString(localRepo.root().resolve(masterOnlyFile), \"Only added in master\");\n            localRepo.add(masterOnlyFile);\n            var updatedMasterHash = CheckableRepository.appendAndCommit(localRepo, \"Master change\");\n            localRepo.push(updatedMasterHash, author.authenticatedUrl(), \"master\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n\n            // Make a merge PR\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"Merge edit\");\n            pr.setBody(\"This is now ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The archive should contain a merge style webrev\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"archive\");\n            assertTrue(archiveContains(archiveFolder.path(), \"The webrev contains the conflicts with master:\"));\n            assertTrue(archiveContains(archiveFolder.path(), pr.id() + \"/00.conflicts\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"2 lines in 2 files changed: 2 ins; 0 del; 0 mod\"));\n\n            // The PR should contain a webrev comment\n            assertEquals(1, pr.comments().size());\n            var webrevComment = pr.comments().get(0);\n            assertTrue(webrevComment.body().contains(\"Merge conflicts\"));\n        }\n    }\n\n    @Test\n    void mergeWebrevNoConflict(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var commenter = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .archiveRef(\"archive\")\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"archive\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Create a merge\n            var editOnlyFile = Path.of(\"editonly.txt\");\n            Files.writeString(localRepo.root().resolve(editOnlyFile), \"Only added in the edit\");\n            localRepo.add(editOnlyFile);\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Edited\", \"Commit in edit branch\");\n            localRepo.checkout(masterHash, true);\n            var masterOnlyFile = Path.of(\"masteronly.txt\");\n            Files.writeString(localRepo.root().resolve(masterOnlyFile), \"Only added in master\");\n            localRepo.add(masterOnlyFile);\n            var updatedMasterHash = localRepo.commit(\"Only added in master\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(updatedMasterHash, author.authenticatedUrl(), \"master\");\n            localRepo.merge(editHash);\n            var mergeCommit = localRepo.commit(\"Merged edit\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(mergeCommit, author.authenticatedUrl(), \"edit\", true);\n\n            // Make a merge PR\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"Merge edit\");\n            pr.setBody(\"This is now ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The archive should contain a merge style webrev\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"archive\");\n            assertTrue(archiveContains(archiveFolder.path(), \"so no merge-specific webrevs have been generated\"));\n\n            // The PR should not contain a webrev comment\n            assertEquals(0, pr.comments().size());\n        }\n    }\n\n    @Test\n    void skipAddingExistingWebrev(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n\n            // Flag it as ready for review\n            pr.setBody(\"This should now be ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain an entry\n            var archiveRepo = Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), editHash.abbreviate()));\n\n            // And there should be a webrev comment\n            var comments = pr.comments();\n            var webrevComments = comments.stream()\n                                         .filter(comment -> comment.author().equals(author.forge().currentUser()))\n                                         .filter(comment -> comment.body().contains(\"webrev\"))\n                                         .filter(comment -> comment.body().contains(editHash.hex()))\n                                         .collect(Collectors.toList());\n            assertEquals(1, webrevComments.size());\n            assertEquals(1, countSubstrings(webrevComments.get(0).body(), pr.id() + \"/00\"));\n\n            // Pretend the archive didn't work out\n            archiveRepo.push(masterHash, archive.authenticatedUrl(), \"master\", true);\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The webrev comment should not contain duplicate entries\n            comments = pr.comments();\n            webrevComments = comments.stream()\n                                     .filter(comment -> comment.author().equals(author.forge().currentUser()))\n                                     .filter(comment -> comment.body().contains(\"webrev\"))\n                                     .filter(comment -> comment.body().contains(editHash.hex()))\n                                     .collect(Collectors.toList());\n            assertEquals(1, webrevComments.size());\n            assertEquals(1, countSubstrings(webrevComments.get(0).body(), pr.id() + \"/00\"));\n        }\n    }\n\n    @Test\n    void notifyReviewVerdicts(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addAuthor(author.forge().currentUser().id());\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // First unapprove it\n            var reviewedPr = reviewer.pullRequest(pr.id());\n            reviewedPr.addReview(Review.Verdict.DISAPPROVED, \"Reason 1\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should contain a note\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"Changes requested by \"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \" by integrationreviewer1\"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"Reason 1\"));\n\n            // Then approve it\n            reviewedPr.addReview(Review.Verdict.APPROVED, \"Reason 2\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should contain another note\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"Marked as reviewed by \"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"Reason 2\"));\n            assertEquals(2, archiveContainsCount(archiveFolder.path(), \"Re: RFR:\"));\n\n            listServer.processIncoming();\n            listServer.processIncoming();\n            listServer.processIncoming();\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertTrue(conversations.get(0).allMessages().get(1).body().contains(\"hosted.git/pr/1/review/0\"));\n            assertTrue(conversations.get(0).allMessages().get(2).body().contains(\"hosted.git/pr/1/review/1\"));\n\n            // Yet another change\n            reviewedPr.addReview(Review.Verdict.DISAPPROVED, \"Reason 3\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should contain another note\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertEquals(2, archiveContainsCount(archiveFolder.path(), \"Changes requested by \"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"Reason 3\"));\n        }\n    }\n\n    @Test\n    void ignoreComments(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                                            .ignoredComments(Set.of(Pattern.compile(\"ignore this comment\", Pattern.MULTILINE | Pattern.DOTALL)))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n\n            // Make a bunch of comments\n            pr.addComment(\"Plain comment\");\n            pr.addComment(\"ignore this comment\");\n            pr.addComment(\"I think it is time to\\nignore this comment!\");\n            pr.addReviewComment(masterHash, editHash, reviewFile.toString(), 2, \"Review ignore this comment\");\n\n            var ignoredPR = ignored.pullRequest(pr.id());\n            ignoredPR.addComment(\"Don't mind me\");\n\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should not contain the ignored comments\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"This is now ready\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"ignore this comment\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"it is time to\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"Don't mind me\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"Review ignore\"));\n        }\n    }\n\n    @Test\n    void replyToEmptyReview(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Make an empty approval\n            var reviewPr = reviewer.pullRequest(pr.id());\n            reviewPr.addReview(Review.Verdict.APPROVED, \"\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            pr.addComment(\"@\" + reviewer.forge().currentUser().username() + \" Thanks for the review!\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The approval text should be included in the quote\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"^> Marked as reviewed\"));\n        }\n    }\n\n    @Test\n    void cooldown(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var bot = credentials.getHostedRepository();\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBotBuilder = MailingListBridgeBot.newBuilder()\n                                                   .from(from)\n                                                   .repo(bot)\n                                                   .ignoredUsers(Set.of(bot.forge().currentUser().username()))\n                                                   .archive(archive)\n                                                   .censusRepo(censusBuilder.build())\n                                                   .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                                   .webrevStorageHTMLRepository(archive)\n                                                   .webrevStorageRef(\"webrev\")\n                                                   .webrevStorageBase(Path.of(\"test\"))\n                                                   .webrevStorageBaseUri(webrevServer.uri())\n                                                   .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                                   .mailingListServer(mailmanServer);\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Line 1\\nLine 2\\nLine 3\\nLine 4\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n\n            var mlBot = mlBotBuilder.build();\n            var mlBotWithCooldown = mlBotBuilder.cooldown(Duration.ofDays(1)).build();\n\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Make a comment\n            pr.addComment(\"Looks good\");\n\n            // Bot with cooldown configured should not bridge the comment\n            TestBotRunner.runPeriodicItems(mlBotWithCooldown);\n            assertThrows(RuntimeException.class, () -> listServer.processIncoming(Duration.ofMillis(1)));\n\n            // But without, it should\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Check the archive\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Looks good\"));\n        }\n    }\n\n    @Test\n    void cooldownNewRevision(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var bot = credentials.getHostedRepository();\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBotBuilder = MailingListBridgeBot.newBuilder()\n                                                   .from(from)\n                                                   .repo(bot)\n                                                   .ignoredUsers(Set.of(bot.forge().currentUser().username()))\n                                                   .archive(archive)\n                                                   .censusRepo(censusBuilder.build())\n                                                   .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                                   .webrevStorageHTMLRepository(archive)\n                                                   .webrevStorageRef(\"webrev\")\n                                                   .webrevStorageBase(Path.of(\"test\"))\n                                                   .webrevStorageBaseUri(webrevServer.uri())\n                                                   .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                                   .mailingListServer(mailmanServer);\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Line 1\\nLine 2\\nLine 3\\nLine 4\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n\n            var mlBot = mlBotBuilder.build();\n            var mlBotWithCooldown = mlBotBuilder.cooldown(Duration.ofDays(1)).build();\n\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Commit another revision\n            var updatedHash = CheckableRepository.appendAndCommit(localRepo, \"More stuff\");\n            localRepo.push(updatedHash, author.authenticatedUrl(), \"edit\");\n\n            // Bot with cooldown configured should not create a new webrev\n            TestBotRunner.runPeriodicItems(mlBotWithCooldown);\n            assertThrows(RuntimeException.class, () -> listServer.processIncoming(Duration.ofMillis(1)));\n\n            // But without, it should\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Check the archive\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), pr.id() + \"/01\"));\n        }\n    }\n\n    @Test\n    void retryAfterCooldown(TestInfo testInfo) throws IOException, InterruptedException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var bot = credentials.getHostedRepository();\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var cooldown = Duration.ofMillis(500);\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBotBuilder = MailingListBridgeBot.newBuilder()\n                                                   .from(from)\n                                                   .repo(bot)\n                                                   .ignoredUsers(Set.of(bot.forge().currentUser().username()))\n                                                   .archive(archive)\n                                                   .censusRepo(censusBuilder.build())\n                                                   .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                                   .webrevStorageHTMLRepository(archive)\n                                                   .webrevStorageRef(\"webrev\")\n                                                   .webrevStorageBase(Path.of(\"test\"))\n                                                   .webrevStorageBaseUri(webrevServer.uri())\n                                                   .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                                   .mailingListServer(mailmanServer);\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Line 1\\nLine 2\\nLine 3\\nLine 4\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n\n            var mlBot = mlBotBuilder.cooldown(cooldown).build();\n            Thread.sleep(cooldown);\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Make a comment and run the check within the cooldown period\n            int counter;\n            boolean noMailReceived = false;\n            for (counter = 1; counter < 10; ++counter) {\n                var start = Instant.now();\n                pr.addComment(\"Looks good - \" + counter + \" -\");\n\n                // The bot should not bridge the comment due to cooldown\n                TestBotRunner.runPeriodicItems(mlBot);\n                try {\n                    noMailReceived = false;\n                    listServer.processIncoming(Duration.ofMillis(1));\n                } catch (RuntimeException e) {\n                    noMailReceived = true;\n                }\n                var elapsed = Duration.between(start, Instant.now());\n                if (elapsed.compareTo(cooldown) < 0) {\n                    break;\n                } else {\n                    log.info(\"Didn't do the test in time - retrying (elapsed: \" + elapsed + \" required: \" + cooldown + \")\");\n                    // Ensure that the cooldown expires\n                    Thread.sleep(cooldown);\n                    // If no mail was received, we have to flush it out\n                    if (noMailReceived) {\n                        TestBotRunner.runPeriodicItems(mlBot);\n                        listServer.processIncoming();\n                    }\n                    cooldown = cooldown.multipliedBy(2);\n                    mlBot = mlBotBuilder.cooldown(cooldown).build();\n                }\n            }\n            assertTrue(noMailReceived);\n\n            // But after the cooldown period has passed, it should\n            Thread.sleep(cooldown);\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Check the archive\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Looks good - \" + counter + \" -\"));\n        }\n    }\n\n    @Test\n    void branchPrefix(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .branchInSubject(Pattern.compile(\".*\"))\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n            pr.setBody(\"This is a PR\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            pr.addComment(\"Looks good!\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Check the archive\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: \\\\[master\\\\] RFR: \"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: Re: \\\\[master\\\\] RFR: \"));\n        }\n    }\n\n    @Test\n    void repoPrefix(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .repoInSubject(true)\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n            pr.setBody(\"This is a PR\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            pr.addComment(\"Looks good!\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Check the archive\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: \\\\[\" + pr.repository().name() + \"\\\\] RFR: \"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: Re: \\\\[\" + pr.repository().name() + \"\\\\] RFR: \"));\n        }\n    }\n\n    @Test\n    void repoAndBranchPrefix(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .repoInSubject(true)\n                                            .branchInSubject(Pattern.compile(\".*\"))\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n            pr.setBody(\"This is a PR\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            pr.addComment(\"Looks good!\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Check the archive\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: \\\\[\" + pr.repository().name() + \":master\\\\] RFR: \"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: Re: \\\\[\" + pr.repository().name() + \":master\\\\] RFR: \"));\n        }\n    }\n\n    @Test\n    void retryNewRevisionAfterCooldown(TestInfo testInfo) throws IOException, InterruptedException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var bot = credentials.getHostedRepository();\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var cooldown = Duration.ofMillis(500);\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBotBuilder = MailingListBridgeBot.newBuilder()\n                                                   .from(from)\n                                                   .repo(bot)\n                                                   .ignoredUsers(Set.of(bot.forge().currentUser().username()))\n                                                   .archive(archive)\n                                                   .censusRepo(censusBuilder.build())\n                                                   .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                                   .webrevStorageHTMLRepository(archive)\n                                                   .webrevStorageRef(\"webrev\")\n                                                   .webrevStorageBase(Path.of(\"test\"))\n                                                   .webrevStorageBaseUri(webrevServer.uri())\n                                                   .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                                   .mailingListServer(mailmanServer);\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Line 1\\nLine 2\\nLine 3\\nLine 4\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n\n            var mlBot = mlBotBuilder.cooldown(cooldown).build();\n            Thread.sleep(cooldown);\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Make a new revision and run the check within the cooldown period\n            int counter;\n            boolean noMailReceived = false;\n            for (counter = 1; counter < 10; ++counter) {\n                var start = Instant.now();\n                var revHash = CheckableRepository.appendAndCommit(localRepo, \"more stuff\", \"Update number - \" + counter + \" -\");\n                localRepo.push(revHash, author.authenticatedUrl(), \"edit\");\n\n                // The bot should not bridge the new revision due to cooldown\n                TestBotRunner.runPeriodicItems(mlBot);\n                try {\n                    noMailReceived = false;\n                    listServer.processIncoming(Duration.ofMillis(1));\n                } catch (RuntimeException e) {\n                    noMailReceived = true;\n                }\n                var elapsed = Duration.between(start, Instant.now());\n                if (elapsed.compareTo(cooldown) < 0) {\n                    break;\n                } else {\n                    log.info(\"Didn't do the test in time - retrying (elapsed: \" + elapsed + \" required: \" + cooldown + \")\");\n                    // Ensure that the cooldown expires\n                    Thread.sleep(cooldown);\n                    // If no mail was received, we have to flush it out\n                    if (noMailReceived) {\n                        TestBotRunner.runPeriodicItems(mlBot);\n                        listServer.processIncoming();\n                    }\n                    cooldown = cooldown.multipliedBy(2);\n                    mlBot = mlBotBuilder.cooldown(cooldown).build();\n                }\n            }\n            assertTrue(noMailReceived);\n\n            // But after the cooldown period has passed, it should\n            Thread.sleep(cooldown);\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Check the archive\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Update number - \" + counter + \" -\"));\n        }\n    }\n\n    @Test\n    void multipleRecipients(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress1 = listServer.createList(\"test1\");\n            var listAddress2 = listServer.createList(\"test2\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress1, Set.of(\"list1\")),\n                                                           new MailingListConfiguration(listAddress2, Set.of(\"list2\"))))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n            pr.setBody(\"This is a PR\");\n            pr.addLabel(\"list1\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The mail should have been sent to list1\n            var mailmanList = mailmanServer.getListReader(listAddress1);\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var mail = conversations.get(0).first();\n            assertEquals(\"RFR: 1234: This is a pull request\", mail.subject());\n            assertEquals(pr.author().fullName(), mail.author().fullName().orElseThrow());\n            assertEquals(from.address(), mail.author().address());\n            assertEquals(listAddress1, mail.sender());\n            assertEquals(List.of(listAddress1), mail.recipients());\n\n            // Add another label and comment\n            pr.addLabel(\"list2\");\n            pr.addComment(\"Looks good!\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // This one should have been sent to list1 and list2\n            conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var reply = conversations.get(0).replies(conversations.get(0).first()).get(0);\n            assertEquals(\"RFR: 1234: This is a pull request\", reply.subject());\n            assertEquals(pr.author().fullName(), reply.author().fullName().orElseThrow());\n            assertEquals(from.address(), reply.author().address());\n            assertEquals(listAddress1, reply.sender());\n            assertEquals(List.of(listAddress1, listAddress2), reply.recipients());\n        }\n    }\n\n    @Test\n    void jsonArchive(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory(false);\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                                            .ignoredComments(Set.of())\n                                            .webrevStorageJSONRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .webrevGenerateHTML(false)\n                                            .webrevGenerateJSON(true)\n                                            .readyLabels(Set.of(\"rfr\"))\n                                            .readyComments(Map.of(ignored.forge().currentUser().username(), Pattern.compile(\"ready\")))\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .headers(Map.of(\"Extra1\", \"val1\", \"Extra2\", \"val2\"))\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n            pr.setBody(\"This should not be ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // A PR that isn't ready for review should not be archived\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertFalse(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n\n            // Flag it as ready for review\n            pr.setBody(\"This should now be ready\");\n            pr.addLabel(\"rfr\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // But it should still not be archived\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertFalse(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n\n            // Now post a general comment - not a ready marker\n            var ignoredPr = ignored.pullRequest(pr.id());\n            ignoredPr.addComment(\"hello there\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // It should still not be archived\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertFalse(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n\n            // Now post a ready comment\n            ignoredPr.addComment(\"ready\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain an entry\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"This should now be ready\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Patch:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Changes:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Webrev:\"));\n            assertTrue(archiveContains(archiveFolder.path(), webrevServer.uri().toString()));\n            assertTrue(archiveContains(archiveFolder.path(), \"&pr=\" + pr.id() + \"&range=00\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Issue:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"http://issues.test/browse/TSTPRJ-1234\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Fetch:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"^ - Change msg\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"With several lines\"));\n\n            // The mailing list as well\n            listServer.processIncoming();\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var mail = conversations.get(0).first();\n            assertEquals(\"RFR: 1234: This is a pull request\", mail.subject());\n            assertEquals(pr.author().fullName(), mail.author().fullName().orElseThrow());\n            assertEquals(from.address(), mail.author().address());\n            assertEquals(listAddress, mail.sender());\n            assertEquals(\"val1\", mail.headerValue(\"Extra1\"));\n            assertEquals(\"val2\", mail.headerValue(\"Extra2\"));\n\n            // And there should be a JSON version of a webrev\n            Repository.materialize(webrevFolder.path(), archive.authenticatedUrl(), \"webrev\");\n            var jsonDir = webrevFolder.path()\n                                      .resolve(author.name())\n                                      .resolve(pr.id())\n                                      .resolve(\"00\");\n            assertTrue(Files.exists(jsonDir));\n            assertTrue(Files.isDirectory(jsonDir));\n\n            var commitsFile = jsonDir.resolve(\"commits.json\");\n            assertTrue(Files.exists(commitsFile));\n            var commits = JSON.parse(Files.readString(commitsFile));\n            assertEquals(1, commits.asArray().size());\n            var commit = commits.get(0);\n            assertEquals(editHash.hex(), commit.get(\"sha\").asString());\n            assertEquals(\"Change msg\\n\\nWith several lines\", commit.get(\"commit\").get(\"message\").asString());\n            assertEquals(1, commit.get(\"files\").asArray().size());\n\n            var metadataFile = jsonDir.resolve(\"metadata.json\");\n            assertTrue(Files.exists(metadataFile));\n            var metadata = JSON.parse(Files.readString(metadataFile));\n            assertEquals(masterHash.hex(), metadata.get(\"base\").get(\"sha\").asString());\n            assertEquals(editHash.hex(), metadata.get(\"head\").get(\"sha\").asString());\n\n            var comparisonFile = jsonDir.resolve(\"comparison.json\");\n            assertTrue(Files.exists(comparisonFile));\n            var comparsion = JSON.parse(Files.readString(comparisonFile));\n            assertEquals(1, comparsion.get(\"files\").asArray().size());\n            assertEquals(\"modified\", comparsion.get(\"files\").get(0).get(\"status\").asString());\n            assertTrue(comparsion.get(\"files\").get(0).get(\"patch\").asString().contains(\"A simple change\"));\n\n            var comments = pr.comments();\n            var webrevComments = comments.stream()\n                                         .filter(comment -> comment.author().equals(author.forge().currentUser()))\n                                         .filter(comment -> comment.body().contains(\"webrev\"))\n                                         .filter(comment -> comment.body().contains(editHash.hex()))\n                                         .collect(Collectors.toList());\n            assertEquals(1, webrevComments.size());\n            var comment = webrevComments.get(0);\n            assertTrue(comment.body().contains(\"&pr=\" + pr.id()));\n            assertTrue(comment.body().contains(\"&range=00\"));\n\n            // Add a comment\n            pr.addComment(\"This is a comment :smile:\");\n\n            // Add a comment from an ignored user as well\n            ignoredPr.addComment(\"Don't mind me\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain the comment, but not the ignored one\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"This is a comment\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"> This should now be ready\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"Don't mind me\"));\n\n            listServer.processIncoming();\n            conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            assertEquals(2, conversations.get(0).allMessages().size());\n\n            // Remove the rfr flag and post another comment\n            pr.addLabel(\"rfr\");\n            pr.addComment(\"@\" + pr.author().username() + \" This is another comment\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should contain the additional comment\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"This is another comment\"));\n            assertTrue(archiveContains(archiveFolder.path(), \">> This should now be ready\"));\n\n            listServer.processIncoming();\n            conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            assertEquals(3, conversations.get(0).allMessages().size());\n            for (var newMail : conversations.get(0).allMessages()) {\n                assertEquals(from.address(), newMail.author().address());\n                assertEquals(listAddress, newMail.sender());\n            }\n            assertTrue(conversations.get(0).allMessages().get(2).body().contains(\"This is a comment 😄\"));\n        }\n    }\n\n    @Test\n    void rebaseOnRetry(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                                            .ignoredComments(Set.of())\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .readyLabels(Set.of(\"rfr\"))\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .headers(Map.of(\"Extra1\", \"val1\", \"Extra2\", \"val2\"))\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n\n            // Flag it as ready for review\n            pr.setBody(\"This should now be ready\");\n            pr.addLabel(\"rfr\");\n\n            // The archive should not yet contain an entry\n            var archiveRepo = Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n\n            // Interfere while creating\n            webrevServer.setHandleCallback(uri -> {\n                try {\n                    var unrelatedFile = archiveRepo.root().resolve(\"unrelated.txt\");\n                    if (!Files.exists(unrelatedFile)) {\n                        Files.writeString(unrelatedFile, \"Unrelated\");\n                        archiveRepo.add(unrelatedFile);\n                        var unrelatedHash = archiveRepo.commit(\"Unrelated\", \"duke\", \"duke@openjdk.org\");\n                        archiveRepo.push(unrelatedHash, archive.authenticatedUrl(), \"master\");\n                    }\n                } catch (IOException e) {\n                    throw new UncheckedIOException(e);\n                }\n            });\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain an entry\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"This should now be ready\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Patch:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Changes:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Webrev:\"));\n            assertTrue(archiveContains(archiveFolder.path(), webrevServer.uri().toString()));\n            assertTrue(archiveContains(archiveFolder.path(), pr.id() + \"/00\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Issue:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"http://issues.test/browse/TSTPRJ-1234\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Fetch:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"^ - Change msg\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"With several lines\"));\n        }\n    }\n\n    @Test\n    void dependent(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Create a separate change\n            var depHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\");\n            localRepo.push(depHash, author.authenticatedUrl(), \"dep\", true);\n            var depPr = credentials.createPullRequest(archive, \"master\", \"dep\", \"The first pr\");\n\n            // Simulate the pr dependency notifier creating the corresponding branch\n            localRepo.push(depHash, author.authenticatedUrl(), \"pr/\" + depPr.id(), true);\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Make a change with a corresponding PR\n            localRepo.checkout(masterHash, true);\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                                                               \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"pr/\" + depPr.id(), \"edit\", \"1234: This is a pull request\");\n            pr.setBody(\"This is a PR\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            pr.addComment(\"Looks good!\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Check the archive\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: RFR: \"), pr.id());\n            assertTrue(archiveContains(archiveFolder.path(), \"Subject: Re: RFR: \", pr.id()));\n\n            assertTrue(archiveContains(archiveFolder.path(), \"Depends on:\", pr.id()));\n            assertFalse(archiveContains(archiveFolder.path(), \"Depends on:\", depPr.id()));\n        }\n    }\n\n    @Test\n    void commentWithQuoteFromBridged(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var bridge = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                                            .from(from)\n                                            .repo(author)\n                                            .archive(archive)\n                                            .censusRepo(censusBuilder.build())\n                                            .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                                            .webrevStorageHTMLRepository(archive)\n                                            .webrevStorageRef(\"webrev\")\n                                            .webrevStorageBase(Path.of(\"test\"))\n                                            .webrevStorageBaseUri(webrevServer.uri())\n                                            .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                                            .mailingListServer(mailmanServer)\n                                            .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.setBody(\"This is now ready\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // Simulate a bridged comment\n            var authorPr = author.pullRequest(pr.id());\n            var bridgedMail = Email.create(\"Re: \" + pr.title(), \"Mailing list comment\\nFirst comment\\nsecond line\")\n                                   .id(EmailAddress.from(\"bridgedemailid@bridge.bridge\"))\n                                   .author(EmailAddress.from(\"List User\", \"listuser@openjdk.org\"))\n                                   .build();\n            BridgedComment.post(authorPr, bridgedMail);\n\n            // And a regular comment\n            pr.addComment(\"Second comment\\nfourth line\");\n\n            // Reply to the bridged one\n            pr.addComment(\">First comm\\n\\nreply to first\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n            listServer.processIncoming();\n\n            // Ensure that the PR is considered again - no duplicates should be sent\n            pr.addLabel(\"ping\");\n            TestBotRunner.runPeriodicItems(mlBot);\n            assertThrows(RuntimeException.class, () -> listServer.processIncoming(Duration.ofMillis(1)));\n\n            // The first comment should be replied to once, and the original post once\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), Pattern.quote(\"List User <listuser@openjdk.org>\") + \".* wrote\"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), Pattern.quote(pr.author().fullName()) + \".* wrote\"));\n\n            // There should have been a reply directed towards the bridged mail id\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), Pattern.quote(\"In-Reply-To: <bridgedemailid@bridge.bridge>\")));\n        }\n    }\n\n    @Test\n    void notArchiveDraftPR(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                    .from(from)\n                    .repo(author)\n                    .archive(archive)\n                    .censusRepo(censusBuilder.build())\n                    .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                    .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                    .ignoredComments(Set.of())\n                    .webrevStorageHTMLRepository(archive)\n                    .webrevStorageRef(\"webrev\")\n                    .webrevStorageBase(Path.of(\"test\"))\n                    .webrevStorageBaseUri(webrevServer.uri())\n                    .readyLabels(Set.of(\"rfr\"))\n                    .readyComments(Map.of(ignored.forge().currentUser().username(), Pattern.compile(\"ready\")))\n                    .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                    .headers(Map.of(\"Extra1\", \"val1\", \"Extra2\", \"val2\"))\n                    .mailingListServer(mailmanServer)\n                    .build();\n\n            // Populate the repository.\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.url(), \"master\", true);\n            localRepo.push(masterHash, archive.url(), \"webrev\", true);\n\n            // Make a change with a corresponding PR.\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                    \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.url(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n            pr.setBody(\"This is not ready now\");\n\n            // Make it as draft, now the PR is not ready.\n            pr.makeDraft();\n\n            // Run an archive pass.\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // A draft PR should not be archived.\n            Repository.materialize(archiveFolder.path(), archive.url(), \"master\");\n            assertFalse(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n\n            // Make it as not draft.\n            pr.makeNotDraft();\n\n            // Flag it as ready for review.\n            pr.setBody(\"This should be ready now\");\n            pr.addLabel(\"rfr\");\n\n            // Post a ready comment.\n            var ignoredPr = ignored.pullRequest(pr.id());\n            ignoredPr.addComment(\"ready\");\n\n            // Run another archive pass.\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain an entry.\n            Repository.materialize(archiveFolder.path(), archive.url(), \"master\");\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"RFR: 1234: This is a pull request\"));\n\n            // Add a comment.\n            pr.addComment(\"This is a comment\");\n\n            // Run another archive pass.\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain the comment.\n            Repository.materialize(archiveFolder.path(), archive.url(), \"master\");\n            assertEquals(2, archiveContainsCount(archiveFolder.path(), \"RFR: 1234: This is a pull request\"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"This is a comment\"));\n\n            // Make it as draft again.\n            pr.makeDraft();\n\n            // Add a new comment.\n            pr.addComment(\"This is a new comment\");\n\n            // Run another archive pass.\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should not now contain the new comment.\n            Repository.materialize(archiveFolder.path(), archive.url(), \"master\");\n            assertEquals(2, archiveContainsCount(archiveFolder.path(), \"RFR: 1234: This is a pull request\"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"This is a comment\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"This is a new comment\"));\n\n            // Make it as not draft again.\n            pr.makeNotDraft();\n\n            // Run another archive pass.\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain the new comment.\n            Repository.materialize(archiveFolder.path(), archive.url(), \"master\");\n            assertEquals(3, archiveContainsCount(archiveFolder.path(), \"RFR: 1234: This is a pull request\"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"This is a comment\"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"This is a new comment\"));\n\n            // Add a new comment before making it as draft.\n            pr.addComment(\"This is a comment before making\");\n\n            // Make it as draft again.\n            pr.makeDraft();\n\n            // Add a new comment after making it as draft.\n            pr.addComment(\"This is a comment after making\");\n\n            // Run another archive pass.\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should only contain the comments before making it as draft.\n            Repository.materialize(archiveFolder.path(), archive.url(), \"master\");\n            assertEquals(4, archiveContainsCount(archiveFolder.path(), \"RFR: 1234: This is a pull request\"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"This is a comment before making\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"This is a comment after making\"));\n\n            // Make it as not draft again.\n            pr.makeNotDraft();\n\n            // Run another archive pass.\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain all the comments.\n            Repository.materialize(archiveFolder.path(), archive.url(), \"master\");\n            assertEquals(5, archiveContainsCount(archiveFolder.path(), \"RFR: 1234: This is a pull request\"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"This is a comment before making\"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"This is a comment after making\"));\n\n            // Push a new commit before making it as draft.\n            var secondHash = CheckableRepository.appendAndCommit(localRepo, \"Second change\", \"Change msg\");\n            localRepo.push(secondHash, author.url(), \"edit\", true);\n\n            // Make it as draft again.\n            pr.makeDraft();\n\n            // Push a new commit after making it as draft.\n            var thirdHash = CheckableRepository.appendAndCommit(localRepo, \"Third change\", \"Change msg\");\n            localRepo.push(thirdHash, author.url(), \"edit\", true);\n\n            // Run another archive pass.\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive shouldn't contain any new commit.\n            Repository.materialize(archiveFolder.path(), archive.url(), \"master\");\n            assertEquals(5, archiveContainsCount(archiveFolder.path(), \"RFR: 1234: This is a pull request\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"RFR: 1234: This is a pull request \\\\[v2\\\\]\"));\n\n            // Make it as not draft again.\n            pr.makeNotDraft();\n\n            // Run another archive pass.\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain all the comments.\n            Repository.materialize(archiveFolder.path(), archive.url(), \"master\");\n            assertEquals(6, archiveContainsCount(archiveFolder.path(), \"RFR: 1234: This is a pull request\"));\n            assertEquals(1, archiveContainsCount(archiveFolder.path(), \"RFR: 1234: This is a pull request \\\\[v2\\\\]\"));\n        }\n    }\n\n    @Test\n    void noWebrev(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                    .from(from)\n                    .repo(author)\n                    .archive(archive)\n                    .censusRepo(censusBuilder.build())\n                    .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                    .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                    .ignoredComments(Set.of())\n                    .webrevStorageHTMLRepository(archive)\n                    .webrevStorageRef(\"webrev\")\n                    .webrevStorageBase(Path.of(\"test\"))\n                    .webrevStorageBaseUri(webrevServer.uri())\n                    .webrevGenerateHTML(false)\n                    .webrevGenerateJSON(false)\n                    .readyLabels(Set.of(\"rfr\"))\n                    .readyComments(Map.of(ignored.forge().currentUser().username(), Pattern.compile(\"ready\")))\n                    .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                    .headers(Map.of(\"Extra1\", \"val1\", \"Extra2\", \"val2\"))\n                    .mailingListServer(mailmanServer)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.url(), \"master\", true);\n            localRepo.push(masterHash, archive.url(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                    \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.url(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n            pr.setBody(\"This should not be ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // A PR that isn't ready for review should not be archived\n            Repository.materialize(archiveFolder.path(), archive.url(), \"master\");\n            assertFalse(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n\n            // Flag it as ready for review\n            pr.setBody(\"This should now be ready\");\n            pr.addLabel(\"rfr\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // But it should still not be archived\n            Repository.materialize(archiveFolder.path(), archive.url(), \"master\");\n            assertFalse(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n\n            // Now post a general comment - not a ready marker\n            var ignoredPr = ignored.pullRequest(pr.id());\n            ignoredPr.addComment(\"hello there\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // It should still not be archived\n            Repository.materialize(archiveFolder.path(), archive.url(), \"master\");\n            assertFalse(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n\n            // Now post a ready comment\n            ignoredPr.addComment(\"ready\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain an entry\n            Repository.materialize(archiveFolder.path(), archive.url(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"This is a pull request\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"This should now be ready\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Patch:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Changes:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Issue:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"http://issues.test/browse/TSTPRJ-1234\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Fetch:\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"^ - Change msg\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"With several lines\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"Webrevs\"));\n\n            // The mailing list as well\n            listServer.processIncoming();\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var mail = conversations.get(0).first();\n            assertEquals(\"RFR: 1234: This is a pull request\", mail.subject());\n            assertEquals(pr.author().fullName(), mail.author().fullName().orElseThrow());\n            assertEquals(from.address(), mail.author().address());\n            assertEquals(listAddress, mail.sender());\n            assertEquals(\"val1\", mail.headerValue(\"Extra1\"));\n            assertEquals(\"val2\", mail.headerValue(\"Extra2\"));\n            assertFalse(mail.body().contains(\"Webrevs\"));\n\n            var nextHash = CheckableRepository.appendAndCommit(localRepo, \"Yet one more line\", \"Fixing\");\n            localRepo.push(nextHash, author.url(), \"edit\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should reference the updated push\n            Repository.materialize(archiveFolder.path(), archive.url(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Patch\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Fetch\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"Fixing\"));\n            assertFalse(archiveContains(archiveFolder.path(), \"Webrevs\"));\n\n            // The mailing list as well\n            listServer.processIncoming();\n            conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var replies = conversations.get(0).replies(mail);\n            var reply = replies.get(0);\n            assertEquals(\"RFR: 1234: This is a pull request [v2]\", reply.subject());\n            assertEquals(pr.author().fullName(), reply.author().fullName().orElseThrow());\n            assertEquals(from.address(), reply.author().address());\n            assertEquals(listAddress, reply.sender());\n            assertEquals(\"val1\", reply.headerValue(\"Extra1\"));\n            assertEquals(\"val2\", reply.headerValue(\"Extra2\"));\n            assertFalse(reply.body().contains(\"Webrevs\"));\n            assertFalse(reply.body().contains(\"- full:\"));\n            assertFalse(reply.body().contains(\"- incr:\"));\n        }\n    }\n\n    @Test\n    void mergeWithoutWebrev(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var commenter = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                    .from(from)\n                    .repo(author)\n                    .archive(archive)\n                    .archiveRef(\"archive\")\n                    .censusRepo(censusBuilder.build())\n                    .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                    .webrevStorageHTMLRepository(archive)\n                    .webrevStorageRef(\"webrev\")\n                    .webrevStorageBase(Path.of(\"test\"))\n                    .webrevStorageBaseUri(webrevServer.uri())\n                    .webrevGenerateJSON(false)\n                    .webrevGenerateHTML(false)\n                    .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                    .mailingListServer(mailmanServer)\n                    .build();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.url(), \"master\", true);\n            localRepo.push(masterHash, archive.url(), \"archive\", true);\n            localRepo.push(masterHash, archive.url(), \"webrev\", true);\n\n            // Create a diverging branch\n            var editOnlyFile = Path.of(\"editonly.txt\");\n            Files.writeString(localRepo.root().resolve(editOnlyFile), \"Only added in the edit\");\n            localRepo.add(editOnlyFile);\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Edited\");\n            localRepo.push(editHash, author.url(), \"edit\");\n\n            // Make conflicting changes in the target\n            localRepo.checkout(masterHash, true);\n            var masterOnlyFile = Path.of(\"masteronly.txt\");\n            Files.writeString(localRepo.root().resolve(masterOnlyFile), \"Only added in master\");\n            localRepo.add(masterOnlyFile);\n            var updatedMasterHash = CheckableRepository.appendAndCommit(localRepo, \"Master change\");\n            localRepo.push(updatedMasterHash, author.url(), \"master\");\n\n            // Perform the merge - resolve conflicts in our favor\n            localRepo.merge(editHash, \"ours\");\n            localRepo.commit(\"Merged edit\", \"duke\", \"duke@openjdk.org\");\n            var mergeOnlyFile = Path.of(\"mergeonly.txt\");\n            Files.writeString(localRepo.root().resolve(mergeOnlyFile), \"Only added in the merge\");\n            localRepo.add(mergeOnlyFile);\n            Files.writeString(localRepo.root().resolve(reviewFile), \"Overwriting the conflict resolution\");\n            localRepo.add(reviewFile);\n            var appendedCommit = localRepo.amend(\"Updated merge commit\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(appendedCommit, author.url(), \"merge_of_edit\", true);\n\n            // Make a merge PR\n            var pr = credentials.createPullRequest(archive, \"master\", \"merge_of_edit\", \"Merge edit\");\n            pr.setBody(\"This is now ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n            listServer.processIncoming();\n\n            // The archive should contain a merge style webrev\n            Repository.materialize(archiveFolder.path(), archive.url(), \"archive\");\n            assertFalse(archiveContains(archiveFolder.path(), \"The webrevs contain the adjustments done while merging with regards to each parent branch:\"));\n            assertFalse(archiveContains(archiveFolder.path(), pr.id() + \"/00.0\"));\n            assertTrue(archiveContains(archiveFolder.path(), \"3 lines in 2 files changed: 1 ins; 1 del; 1 mod\"));\n\n            // The PR should not contain a webrev comment\n            assertEquals(0, pr.comments().size());\n        }\n    }\n\n    @Test\n    void archiveLongBody(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                    .from(from)\n                    .repo(author)\n                    .archive(archive)\n                    .censusRepo(censusBuilder.build())\n                    .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                    .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                    .ignoredComments(Set.of())\n                    .webrevStorageHTMLRepository(archive)\n                    .webrevStorageRef(\"webrev\")\n                    .webrevStorageBase(Path.of(\"test\"))\n                    .webrevStorageBaseUri(webrevServer.uri())\n                    .readyLabels(Set.of(\"rfr\"))\n                    .readyComments(Map.of(ignored.forge().currentUser().username(), Pattern.compile(\"ready\")))\n                    .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                    .headers(Map.of(\"Extra1\", \"val1\", \"Extra2\", \"val2\"))\n                    .mailingListServer(mailmanServer)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\",\n                    \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n\n            // Flag it as ready for review\n            pr.setBody(\"This should now be ready\" + \"0\".repeat(10000));\n            pr.addLabel(\"rfr\");\n\n            var ignoredPr = ignored.pullRequest(pr.id());\n\n            // Now post a ready comment\n            ignoredPr.addComment(\"ready\");\n\n            //skara command prefixed with non-white space - should be archived\n            pr.addComment(\"do not ignore me /help\");\n\n            //valid skara command - should not be archived\n            pr.addComment(\"/help\");\n\n            //Invalid skara command but starting with '/' - should be archived\n            pr.addComment(\"/some-text & more text\");\n\n            //Not a valid skara command with upper case letter - should be archived\n            pr.addComment(\"/Help\");\n\n            // Run another archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            // Should contain truncated pr body\n            assertTrue(archiveContains(archiveFolder.path(), pr.store().body().substring(0, 2500) + \"...\"));\n\n            // The mailing list as well\n            listServer.processIncoming();\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var mail = conversations.get(0).first();\n            assertEquals(\"RFR: 1234: This is a pull request\", mail.subject());\n            assertEquals(pr.author().fullName(), mail.author().fullName().orElseThrow());\n            assertEquals(from.address(), mail.author().address());\n            assertEquals(listAddress, mail.sender());\n            assertEquals(\"val1\", mail.headerValue(\"Extra1\"));\n            assertEquals(\"val2\", mail.headerValue(\"Extra2\"));\n            // The first mail should contain full pr body\n            assertTrue(mail.body().contains(pr.store().body()));\n        }\n    }\n\n    @Test\n    void largeDiffArchive(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var archiveFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n            var ignored = credentials.getHostedRepository();\n            var listAddress = listServer.createList(\"test\");\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id());\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(),\n                    listServer.getSMTP(), Duration.ZERO);\n            var mlBot = MailingListBridgeBot.newBuilder()\n                    .from(from)\n                    .repo(author)\n                    .archive(archive)\n                    .censusRepo(censusBuilder.build())\n                    .lists(List.of(new MailingListConfiguration(listAddress, Set.of())))\n                    .ignoredUsers(Set.of(ignored.forge().currentUser().username()))\n                    .ignoredComments(Set.of())\n                    .webrevStorageHTMLRepository(archive)\n                    .webrevStorageRef(\"webrev\")\n                    .webrevStorageBase(Path.of(\"test\"))\n                    .webrevStorageBaseUri(webrevServer.uri())\n                    .readyLabels(Set.of(\"rfr\"))\n                    .readyComments(Map.of(ignored.forge().currentUser().username(), Pattern.compile(\"ready\")))\n                    .issueTracker(URIBuilder.base(\"http://issues.test/browse/\").build())\n                    .headers(Map.of(\"Extra1\", \"val1\", \"Extra2\", \"val2\"))\n                    .mailingListServer(mailmanServer)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a very big change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change\\n\".repeat(300001),\n                    \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"1234: This is a pull request\");\n            pr.setBody(\"This should not be ready\");\n\n            // Flag it as ready for review\n            pr.setBody(\"This should now be ready\");\n            pr.addLabel(\"rfr\");\n\n            // Post a ready comment\n            var ignoredPr = ignored.pullRequest(pr.id());\n            ignoredPr.addComment(\"ready\");\n\n            // Run an archive pass\n            TestBotRunner.runPeriodicItems(mlBot);\n\n            // The archive should now contain an entry\n            Repository.materialize(archiveFolder.path(), archive.authenticatedUrl(), \"master\");\n            assertTrue(archiveContains(archiveFolder.path(), \"Webrev: Webrev is not available because diff is too large\"));\n\n            assertTrue(pr.store().comments().get(1).body().contains(\"[Full](Webrev is not available because diff is too large)\"));\n            // The mailing list as well\n            listServer.processIncoming();\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var mail = conversations.get(0).first();\n            assertTrue(mail.body().contains(\"Webrev: Webrev is not available because diff is too large\"));\n\n            // And there shouldn't be a webrev\n            Repository.materialize(webrevFolder.path(), archive.authenticatedUrl(), \"webrev\");\n            assertFalse(Files.exists(webrevFolder.path().resolve(\"test\")));\n\n            // Make a small change\n            editHash = CheckableRepository.appendAndCommit(localRepo, \"A simple change 2\",\n                    \"Change msg\\n\\nWith several lines\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n\n            TestBotRunner.runPeriodicItems(mlBot);\n            assertTrue(pr.store().comments().get(1).body().contains(\"webrevs/test/1/00-01)\"));\n            listServer.processIncoming();\n            conversations = mailmanList.conversations(Duration.ofDays(1));\n            mail = conversations.get(0).allMessages().get(1);\n            assertTrue(mail.body().contains(\"webrevs/test/1/00-01\"));\n\n            // And there should be a webrev\n            Repository.materialize(webrevFolder.path(), archive.authenticatedUrl(), \"webrev\");\n            assertTrue(webrevContains(webrevFolder.path(), \"1 lines changed\"));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/test/java/org/openjdk/skara/bots/mlbridge/MarkdownToTextTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotEquals;\n\nclass MarkdownToTextTests {\n    @Test\n    void emoji() {\n        assertEquals(\"😄\", MarkdownToText.removeFormatting(\":smile:\"));\n        assertEquals(\"yay 😄\", MarkdownToText.removeFormatting(\"yay :smile:\"));\n        assertEquals(\"😄\\n😄\", MarkdownToText.removeFormatting(\":smile:\\n:smile:\"));\n        assertEquals(\"😄 🙁\", MarkdownToText.removeFormatting(\":smile: :slight_frown:\"));\n        assertEquals(\"😄 🙁 :meh:\", MarkdownToText.removeFormatting(\":smile: :slight_frown: :meh:\"));\n    }\n\n    @Test\n    void patterns() {\n        for (var emojiMap : EmojiTable.mapping.entrySet()) {\n            var pattern = \":\" + emojiMap.getKey() + \":\";\n            assertNotEquals(pattern, MarkdownToText.removeFormatting(pattern));\n        }\n    }\n\n    @Test\n    void code() {\n        assertEquals(\"Just some text\", MarkdownToText.removeFormatting(\"```\\nJust some text\\n```\"));\n        assertEquals(\"Multi\\nline\", MarkdownToText.removeFormatting(\"```\\nMulti\\nline\\n```\"));\n        assertEquals(\"Script\", MarkdownToText.removeFormatting(\"```bash\\nScript\\n```\"));\n        assertEquals(\"Longer\", MarkdownToText.removeFormatting(\"`````bash\\nLonger\\n`````\"));\n        assertEquals(\"``bash\\nShorter\\n``\", MarkdownToText.removeFormatting(\"``bash\\nShorter\\n``\"));\n    }\n\n    @Test\n    void suggestion() {\n        assertEquals(\"Suggestion:\\n\\nJust some text\", MarkdownToText.removeFormatting(\"```suggestion\\nJust some text\\n```\"));\n    }\n\n    @Test\n    void escapes() {\n        assertEquals(\"Special chars: #$%&'()*+\\\\!\", MarkdownToText.removeFormatting(\"Special chars: \\\\#\\\\$\\\\%\\\\&\\\\'\\\\(\\\\)\\\\*\\\\+\\\\\\\\!\"));\n    }\n\n    @Test\n    void entities() {\n        assertEquals(\"space is here\", MarkdownToText.removeFormatting(\"space&#32;is here\"));\n    }\n\n    @Test\n    void singleLineCode() {\n        assertEquals(\"```assert_locked_or_safepoint(Threads_lock);```\", MarkdownToText.removeFormatting(\"```assert_locked_or_safepoint(Threads_lock);```\"));\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/test/java/org/openjdk/skara/bots/mlbridge/QuoteFilterTests.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.net.URI;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\npublic class QuoteFilterTests {\n    @Test\n    void simple() {\n        assertEquals(\"a\\nb\", QuoteFilter.stripLinkBlock(\"a\\nb\", URI.create(\"http://test\")));\n        assertEquals(\"a\", QuoteFilter.stripLinkBlock(\"a\\n> b\\n> http://test\", URI.create(\"http://test\")));\n        assertEquals(\"a\\nc\", QuoteFilter.stripLinkBlock(\"a\\n> b\\n> http://test\\nc\", URI.create(\"http://test\")));\n        assertEquals(\"a\\nc\", QuoteFilter.stripLinkBlock(\"a\\n> > b\\n> http://test\\nc\", URI.create(\"http://test\")));\n        assertEquals(\"a\\n> b\\nc\", QuoteFilter.stripLinkBlock(\"a\\n> b\\n> > http://test\\nc\", URI.create(\"http://test\")));\n        assertEquals(\"a\\n> b\\nc\", QuoteFilter.stripLinkBlock(\"a\\n> b\\n> > http://test\\n> > d\\nc\", URI.create(\"http://test\")));\n    }\n\n    @Test\n    void notQuoted() {\n        assertEquals(\"a\\nhttp://test\", QuoteFilter.stripLinkBlock(\"a\\nhttp://test\", URI.create(\"http://test\")));\n    }\n\n    @Test\n    void trailingSpace() {\n        assertEquals(\"a\\nc\", QuoteFilter.stripLinkBlock(\"a\\n>> b\\n>>\\n>> http://test\\nc\", URI.create(\"http://test\")));\n    }\n\n}\n"
  },
  {
    "path": "bots/mlbridge/src/test/java/org/openjdk/skara/bots/mlbridge/TextToMarkdownTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass TextToMarkdownTests {\n    @Test\n    void punctuation() {\n        assertEquals(\"1\\\\. Not a list\", TextToMarkdown.escapeFormatting(\"1. Not a list\"));\n        assertEquals(\"\\\\*not emphasized\\\\*\", TextToMarkdown.escapeFormatting(\"*not emphasized*\"));\n        assertEquals(\"\\\\\\\\n\", TextToMarkdown.escapeFormatting(\"\\\\n\"));\n    }\n\n    @Test\n    void indented() {\n        assertEquals(\"&#32;      hello\", TextToMarkdown.escapeFormatting(\"       hello\"));\n    }\n\n    @Test\n    void preserveQuoting() {\n        assertEquals(\"> quoted\", TextToMarkdown.escapeFormatting(\"> quoted\"));\n    }\n\n    @Test\n    void escapedPattern() {\n        assertEquals(\"1\\\\$2\", TextToMarkdown.escapeFormatting(\"1$2\"));\n    }\n\n    @Test\n    void separateQuoteBlocks() {\n        assertEquals(\"> 1\\n\\n2\", TextToMarkdown.escapeFormatting(\"> 1\\n2\"));\n        assertEquals(\"> 1\\n\\n2\", TextToMarkdown.escapeFormatting(\"> 1\\n\\n2\"));\n        assertEquals(\"> 1\\n> 2\\n\\n3\", TextToMarkdown.escapeFormatting(\"> 1\\n> 2\\n3\"));\n        assertEquals(\"> 1\\n> 2\\n\\n3\", TextToMarkdown.escapeFormatting(\"> 1\\n> 2\\n\\n3\"));\n    }\n\n    @Test\n    void mention() {\n        assertEquals(\"1\\\\@<!-- -->2\", TextToMarkdown.escapeFormatting(\"1@2\"));\n    }\n}\n"
  },
  {
    "path": "bots/mlbridge/src/test/java/org/openjdk/skara/bots/mlbridge/WebrevStorageTests.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.mlbridge;\n\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.forge.HostedRepositoryPool;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.*;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.UUID;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass WebrevStorageTests {\n    @Test\n    void overwriteExisting(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Check that the web link wasn't verified yet\n            assertFalse(webrevServer.isChecked());\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.addLabel(\"rfr\");\n            pr.setBody(\"This is now ready\");\n\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var storage = new WebrevStorage(archive, \"webrev\", Path.of(\"test\"),\n                                            webrevServer.uri(), from);\n\n            var prFolder = tempFolder.path().resolve(\"pr\");\n            var prRepo = Repository.materialize(prFolder, pr.repository().authenticatedUrl(), \"edit\");\n            var jsonScratchFolder = tempFolder.path().resolve(\"scratch\").resolve(\"json\");\n            var htmlScratchFolder = tempFolder.path().resolve(\"scratch\").resolve(\"html\");\n            var seedPath = tempFolder.path().resolve(\"seed\");\n            var generator = storage.generator(pr, prRepo, jsonScratchFolder, htmlScratchFolder, new HostedRepositoryPool(seedPath));\n            generator.generate(masterHash, editHash, \"00\", WebrevDescription.Type.FULL);\n\n            // Check that the web link has been verified now and followed the redirect\n            assertTrue(webrevServer.isChecked());\n            assertTrue(webrevServer.isRedirectFollowed());\n\n            // Update the local repository and check that the webrev has been generated\n            Repository.materialize(repoFolder, archive.authenticatedUrl(), \"webrev\");\n            assertTrue(Files.exists(repoFolder.resolve(\"test/\" + pr.id() + \"/00/index.html\")));\n\n            // Create it again - it will overwrite the previous one\n            generator.generate(masterHash, editHash, \"00\", WebrevDescription.Type.FULL);\n            Repository.materialize(repoFolder, archive.authenticatedUrl(), \"webrev\");\n            assertTrue(Files.exists(repoFolder.resolve(\"test/\" + pr.id() + \"/00/index.html\")));\n        }\n    }\n\n    @Test\n    void dropLarge(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            CheckableRepository.appendAndCommit(localRepo);\n            var large = \"This line is about 30 bytes long\\n\".repeat(1000 * 100);\n            Files.writeString(repoFolder.resolve(\"large.txt\"), large);\n            localRepo.add(repoFolder.resolve(\"large.txt\"));\n            var editHash = localRepo.commit(\"Add large file\", \"duke\", \"duke@openjdk.org\");\n\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.addLabel(\"rfr\");\n            pr.setBody(\"This is now ready\");\n\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var storage = new WebrevStorage(archive, \"webrev\", Path.of(\"test\"),\n                                            webrevServer.uri(), from);\n\n            var prFolder = tempFolder.path().resolve(\"pr\");\n            var prRepo = Repository.materialize(prFolder, pr.repository().authenticatedUrl(), \"edit\");\n            var jsonScratchFolder = tempFolder.path().resolve(\"scratch\").resolve(\"json\");\n            var htmlScratchFolder = tempFolder.path().resolve(\"scratch\").resolve(\"html\");\n            var seedPath = tempFolder.path().resolve(\"seed\");\n            var generator = storage.generator(pr, prRepo, jsonScratchFolder, htmlScratchFolder, new HostedRepositoryPool(seedPath));\n            generator.generate(masterHash, editHash, \"00\", WebrevDescription.Type.FULL);\n\n            // Update the local repository and check that the webrev has been generated\n            Repository.materialize(repoFolder, archive.authenticatedUrl(), \"webrev\");\n            assertTrue(Files.exists(repoFolder.resolve(\"test/\" + pr.id() + \"/00/index.html\")));\n            assertTrue(Files.size(repoFolder.resolve(\"test/\" + pr.id() + \"/00/large.txt\")) > 0);\n            assertTrue(Files.size(repoFolder.resolve(\"test/\" + pr.id() + \"/00/large.txt\")) < 1000);\n        }\n    }\n\n    private static class InterceptingHash extends Hash {\n        private final Path generatorPath;\n        private final Path scratchPath;\n        private final HostedRepository archive;\n        private final String ref;\n\n        private boolean hasIntercepted = false;\n\n        public InterceptingHash(String hex, Path generatorPath, Path scratchPath, HostedRepository archive, String ref) {\n            super(hex);\n\n            this.generatorPath = generatorPath;\n            this.scratchPath = scratchPath;\n            this.archive = archive;\n            this.ref = ref;\n        }\n\n        @Override\n        public String hex() {\n            if (Files.exists(generatorPath)) {\n                if (hasIntercepted) {\n                    return super.hex();\n                }\n\n                try {\n                    var repo = Repository.materialize(scratchPath, archive.authenticatedUrl(), ref);\n                    Files.writeString(repo.root().resolve(\"intercept.txt\"), UUID.randomUUID().toString());\n                    repo.add(repo.root().resolve(\"intercept.txt\"));\n                    var commit = repo.commit(\"Concurrent unrelated commit\", \"duke\", \"duke@openjdk.org\");\n                    repo.push(commit, archive.authenticatedUrl(), ref);\n                    hasIntercepted = true;\n                } catch (IOException e) {\n                    throw new UncheckedIOException(e);\n                }\n                System.out.println(\"Pushing an unrelated commit to the archive repo\");\n            } else {\n                hasIntercepted = false;\n            }\n            return super.hex();\n        }\n    }\n\n    @Test\n    void retryConcurrentPush(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var webrevServer = new TestWebrevServer()) {\n            var author = credentials.getHostedRepository();\n            var archive = credentials.getHostedRepository();\n\n            // Populate the projects repository\n            var reviewFile = Path.of(\"reviewfile.txt\");\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, author.repositoryType(), reviewFile);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, archive.authenticatedUrl(), \"webrev\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(archive, \"master\", \"edit\", \"This is a pull request\");\n            pr.addLabel(\"rfr\");\n            pr.setBody(\"This is now ready\");\n\n            var from = EmailAddress.from(\"test\", \"test@test.mail\");\n            var storage = new WebrevStorage(archive, \"webrev\", Path.of(\"test\"),\n                                            webrevServer.uri(), from);\n\n            var prFolder = tempFolder.path().resolve(\"pr\");\n            var prRepo = Repository.materialize(prFolder, pr.repository().authenticatedUrl(), \"edit\");\n            var jsonScratchFolder = tempFolder.path().resolve(\"scratch\").resolve(\"json\");\n            var htmlScratchFolder = tempFolder.path().resolve(\"scratch\").resolve(\"html\");\n            var generatorProgressMarker = htmlScratchFolder.resolve(\"test/\" + pr.id() + \"/00/nanoduke.ico\");\n            var seedPath = tempFolder.path().resolve(\"seed\");\n            var generator = storage.generator(pr, prRepo, jsonScratchFolder, htmlScratchFolder, new HostedRepositoryPool(seedPath));\n\n            // Commit something during generation\n            var interceptFolder = tempFolder.path().resolve(\"intercept\");\n            var interceptEditHash = new InterceptingHash(editHash.hex(),\n                                                         generatorProgressMarker,\n                                                         interceptFolder, archive, \"webrev\");\n            generator.generate(masterHash, interceptEditHash, \"00\", WebrevDescription.Type.FULL);\n\n            // Update the local repository and check that the webrev has been generated\n            var archiveRepo = Repository.materialize(repoFolder, archive.authenticatedUrl(), \"webrev\");\n            assertTrue(Files.exists(repoFolder.resolve(\"test/\" + pr.id() + \"/00/index.html\")));\n\n            // The intercepting commit should be present in the history\n            assertTrue(archiveRepo.commitMetadata().stream()\n                                  .anyMatch(cm -> cm.message().get(0).equals(\"Concurrent unrelated commit\")));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/notify/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.notify'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        requires 'org.openjdk.skara.proxy'\n        opens 'org.openjdk.skara.bots.notify' to 'org.junit.platform.commons'\n        opens 'org.openjdk.skara.bots.notify.mailinglist' to 'org.junit.platform.commons'\n        opens 'org.openjdk.skara.bots.notify.json' to 'org.junit.platform.commons'\n        opens 'org.openjdk.skara.bots.notify.issue' to 'org.junit.platform.commons'\n        opens 'org.openjdk.skara.bots.notify.comment' to 'org.junit.platform.commons'\n        opens 'org.openjdk.skara.bots.notify.prbranch' to 'org.junit.platform.commons'\n        opens 'org.openjdk.skara.bots.notify.notes' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':ci')\n    implementation project(':host')\n    implementation project(':network')\n    implementation project(':bot')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':json')\n    implementation project(':census')\n    implementation project(':vcs')\n    implementation project(':jcheck')\n    implementation project(':email')\n    implementation project(':storage')\n    implementation project(':mailinglist')\n    implementation project(':jbs')\n    implementation project(':metrics')\n    implementation project(':bots:common')\n\n    testImplementation project(':test')\n    testImplementation project(':proxy')\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.bots.notify {\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.jcheck;\n    requires org.openjdk.skara.email;\n    requires org.openjdk.skara.storage;\n    requires org.openjdk.skara.mailinglist;\n    requires org.openjdk.skara.network;\n    requires org.openjdk.skara.jbs;\n    requires org.openjdk.skara.bots.common;\n    requires java.logging;\n    requires java.net.http;\n\n    exports org.openjdk.skara.bots.notify;\n\n    provides org.openjdk.skara.bot.BotFactory with org.openjdk.skara.bots.notify.NotifyBotFactory;\n\n    uses org.openjdk.skara.bots.notify.NotifierFactory;\n    provides org.openjdk.skara.bots.notify.NotifierFactory with\n            org.openjdk.skara.bots.notify.issue.IssueNotifierFactory,\n            org.openjdk.skara.bots.notify.json.JsonNotifierFactory,\n            org.openjdk.skara.bots.notify.mailinglist.MailingListNotifierFactory,\n            org.openjdk.skara.bots.notify.slack.SlackNotifierFactory,\n            org.openjdk.skara.bots.notify.comment.CommitCommentNotifierFactory,\n            org.openjdk.skara.bots.notify.prbranch.PullRequestBranchNotifierFactory,\n            org.openjdk.skara.bots.notify.notes.CommitNoteNotifierFactory;\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/CommitFormatters.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.time.format.DateTimeFormatter;\n\npublic class CommitFormatters {\n    public static String toTextBrief(HostedRepository repository, Commit commit, Branch branch) {\n        var writer = new StringWriter();\n        var printer = new PrintWriter(writer);\n\n        printer.println(\"Changeset: \" + commit.hash().abbreviate());\n        if (branch != null) {\n            printer.println(\"Branch: \" + branch.name());\n        }\n        printer.println(\"Author:    \" + commit.author().name() + \" <\" + commit.author().email() + \">\");\n        if (!commit.author().equals(commit.committer())) {\n            printer.println(\"Committer: \" + commit.committer().name() + \" <\" + commit.committer().email() + \">\");\n        }\n        printer.println(\"Date:      \" + commit.authored().format(DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm:ss +0000\")));\n        printer.println(\"URL:       \" + repository.webUrl(commit.hash()));\n\n        return writer.toString();\n    }\n\n    private static String patchToText(Patch patch) {\n        if (patch.status().isAdded()) {\n            return \"+ \" + patch.target().path().orElseThrow();\n        } else if (patch.status().isDeleted()) {\n            return \"- \" + patch.source().path().orElseThrow();\n        } else if (patch.status().isModified()) {\n            return \"! \" + patch.target().path().orElseThrow();\n        } else {\n            return \"= \" + patch.target().path().orElseThrow();\n        }\n    }\n\n    public static String toText(HostedRepository repository, Commit commit, Branch branch) {\n        var writer = new StringWriter();\n        var printer = new PrintWriter(writer);\n\n        printer.print(toTextBrief(repository, commit, branch));\n        printer.println();\n        printer.println(String.join(\"\\n\", commit.message()));\n        printer.println();\n\n        for (var diff : commit.parentDiffs()) {\n            for (var patch : diff.patches()) {\n                printer.println(patchToText(patch));\n            }\n        }\n\n        return writer.toString();\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/Emitter.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\npackage org.openjdk.skara.bots.notify;\n\npublic interface Emitter {\n    void registerPullRequestListener(PullRequestListener listener);\n    void registerRepositoryListener(RepositoryListener listener);\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/NonRetriableException.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\npublic class NonRetriableException extends Exception {\n    private final Throwable cause;\n\n    public NonRetriableException(Throwable cause) {\n        this.cause = cause;\n    }\n\n    public Throwable cause() {\n        return cause;\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/Notifier.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.openjdk.skara.bot.BotConfiguration;\nimport org.openjdk.skara.json.JSONObject;\n\npublic interface Notifier {\n    static Notifier create(String name, BotConfiguration botConfiguration, JSONObject notifierConfiguration) {\n        var factory = NotifierFactory.getNotifierFactories().stream()\n                .filter(f -> f.name().equals(name))\n                .findFirst();\n        if (factory.isEmpty()) {\n            throw new RuntimeException(\"No notifier factory named '\" + name + \"' found - check module path\");\n        }\n        return factory.get().create(botConfiguration, notifierConfiguration);\n    }\n\n    void attachTo(Emitter e);\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/NotifierFactory.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.openjdk.skara.bot.BotConfiguration;\nimport org.openjdk.skara.json.JSONObject;\n\nimport java.util.*;\nimport java.util.stream.*;\n\npublic interface NotifierFactory {\n    /**\n     * A user-friendly name for the given notifier, used for configuration section naming. Should be lower case.\n     * @return\n     */\n    String name();\n\n    /**\n     * Creates instances of this notifier according to the provided configuration.\n     * @return\n     */\n    Notifier create(BotConfiguration botConfiguration, JSONObject notifierConfiguration);\n\n    static List<NotifierFactory> getNotifierFactories() {\n        return StreamSupport.stream(ServiceLoader.load(NotifierFactory.class).spliterator(), false)\n                .collect(Collectors.toList());\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/NotifyBot.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.storage.StorageBuilder;\n\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\n\npublic class NotifyBot implements Bot, Emitter {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");\n    private final HostedRepository repository;\n    private final Path storagePath;\n    private final Pattern branches;\n    private final StorageBuilder<UpdatedTag> tagStorageBuilder;\n    private final StorageBuilder<UpdatedBranch> branchStorageBuilder;\n    private final StorageBuilder<PullRequestState> prStateStorageBuilder;\n    private final List<RepositoryListener> repoListeners = new ArrayList<>();\n    private final List<PullRequestListener> prListeners = new ArrayList<>();\n    private final Map<String, Pattern> readyComments;\n    private final String integratorId;\n    private final PullRequestPoller poller;\n    private Boolean firstTimeCall = true;\n\n    NotifyBot(HostedRepository repository, Path storagePath, Pattern branches, StorageBuilder<UpdatedTag> tagStorageBuilder,\n              StorageBuilder<UpdatedBranch> branchStorageBuilder, StorageBuilder<PullRequestState> prStateStorageBuilder,\n              Map<String, Pattern> readyComments, String integratorId) {\n        this.repository = repository;\n        this.storagePath = storagePath;\n        this.branches = branches;\n        this.tagStorageBuilder = tagStorageBuilder;\n        this.branchStorageBuilder = branchStorageBuilder;\n        this.prStateStorageBuilder = prStateStorageBuilder;\n        this.readyComments = readyComments;\n        this.integratorId = integratorId;\n        this.poller = new PullRequestPoller(repository, true);\n    }\n\n    public static NotifyBotBuilder newBuilder() {\n        return new NotifyBotBuilder();\n    }\n\n    @Override\n    public void registerPullRequestListener(PullRequestListener listener) {\n        prListeners.add(listener);\n    }\n\n    @Override\n    public void registerRepositoryListener(RepositoryListener listener) {\n        repoListeners.add(listener);\n    }\n\n    @Override\n    public String toString() {\n        return \"NotifyBot@\" + repository.name();\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        var ret = new ArrayList<WorkItem>();\n\n        if (firstTimeCall) {\n            prListeners.forEach(listener -> listener.initialize(repository));\n            firstTimeCall = false;\n        }\n\n        if (!prListeners.isEmpty()) {\n            // Pull request events\n            List<PullRequest> prs = poller.updatedPullRequests();\n            for (var pr : prs) {\n                ret.add(new PullRequestWorkItem(pr,\n                        prStateStorageBuilder,\n                        prListeners,\n                        e -> poller.retryPullRequest(pr),\n                        integratorId,\n                        readyComments));\n            }\n            poller.lastBatchHandled();\n        }\n\n        // Repository events\n        if (!repoListeners.isEmpty()) {\n            ret.add(new RepositoryWorkItem(repository, storagePath, branches, tagStorageBuilder, branchStorageBuilder, repoListeners));\n        }\n\n        return ret;\n    }\n\n    @Override\n    public String name() {\n        return NotifyBotFactory.NAME;\n    }\n\n    public Pattern getBranches() {\n        return branches;\n    }\n\n    public Map<String, Pattern> getReadyComments() {\n        return readyComments;\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/NotifyBotBuilder.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.storage.StorageBuilder;\n\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\npublic class NotifyBotBuilder {\n    private HostedRepository repository;\n    private Path storagePath;\n    private Pattern branches;\n    private StorageBuilder<UpdatedTag> tagStorageBuilder;\n    private StorageBuilder<UpdatedBranch> branchStorageBuilder;\n    private StorageBuilder<PullRequestState> prStateStorageBuilder;\n    private Map<String, Pattern> readyComments = Map.of();\n    private String integratorId;\n\n    public NotifyBotBuilder repository(HostedRepository repository) {\n        this.repository = repository;\n        return this;\n    }\n\n    public NotifyBotBuilder storagePath(Path storagePath) {\n        this.storagePath = storagePath;\n        return this;\n    }\n\n    public NotifyBotBuilder branches(Pattern branches) {\n        this.branches = branches;\n        return this;\n    }\n\n    public NotifyBotBuilder tagStorageBuilder(StorageBuilder<UpdatedTag> tagStorageBuilder) {\n        this.tagStorageBuilder = tagStorageBuilder;\n        return this;\n    }\n\n    public NotifyBotBuilder branchStorageBuilder(StorageBuilder<UpdatedBranch> branchStorageBuilder) {\n        this.branchStorageBuilder = branchStorageBuilder;\n        return this;\n    }\n\n    public NotifyBotBuilder prStateStorageBuilder(StorageBuilder<PullRequestState> prStateStorageBuilder) {\n        this.prStateStorageBuilder = prStateStorageBuilder;\n        return this;\n    }\n\n    public NotifyBotBuilder readyComments(Map<String, Pattern> readyComments) {\n        this.readyComments = readyComments;\n        return this;\n    }\n\n    public NotifyBotBuilder integratorId(String integratorId) {\n        this.integratorId = integratorId;\n        return this;\n    }\n\n    public NotifyBot build() {\n        return new NotifyBot(repository, storagePath, branches, tagStorageBuilder, branchStorageBuilder, prStateStorageBuilder, readyComments, integratorId);\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/NotifyBotFactory.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.openjdk.skara.vcs.Branch;\nimport org.openjdk.skara.vcs.VCS;\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.storage.StorageBuilder;\n\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class NotifyBotFactory implements BotFactory {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n\n    static final String NAME = \"notify\";\n    @Override\n    public String name() {\n        return NAME;\n    }\n\n    private JSONObject combineConfiguration(JSONObject global, JSONObject specific) {\n        var ret = new JSONObject();\n        if (global != null) {\n            for (var globalField : global.fields()) {\n                ret.put(globalField.name(), globalField.value());\n            }\n        }\n        for (var specificField : specific.fields()) {\n            ret.put(specificField.name(), specificField.value());\n        }\n        return ret;\n    }\n\n    @Override\n    public List<Bot> create(BotConfiguration configuration) {\n        var ret = new ArrayList<Bot>();\n        var specific = configuration.specific();\n\n        var database = specific.get(\"database\").asObject();\n        var databaseRepo = configuration.repository(database.get(\"repository\").asString());\n        var databaseRef = configuration.repositoryRef(database.get(\"repository\").asString());\n        var databaseName = database.get(\"name\").asString();\n        var databaseEmail = database.get(\"email\").asString();\n\n        var readyComments = specific.get(\"ready\").get(\"comments\").stream()\n                                    .map(JSONValue::asObject)\n                                    .collect(Collectors.toMap(obj -> obj.get(\"user\").asString(),\n                                                              obj -> Pattern.compile(obj.get(\"pattern\").asString())));\n        var integratorId = specific.get(\"integrator\").asString();\n\n        // Collect configuration applicable to all instances of a specific notifier\n        var notifierFactories = NotifierFactory.getNotifierFactories();\n        notifierFactories.forEach(notifierFactory -> log.info(\"Available notifier: \" + notifierFactory.name()));\n        var notifierConfiguration = new HashMap<String, JSONObject>();\n        for (var notifierFactory : notifierFactories) {\n            if (specific.contains(notifierFactory.name())) {\n                notifierConfiguration.put(notifierFactory.name(), specific.get(notifierFactory.name()).asObject());\n            }\n        }\n\n        for (var repo : specific.get(\"repositories\").fields()) {\n            var repoName = repo.name();\n            var branchPattern = Pattern.compile(\"^\" + Branch.defaultFor(VCS.GIT).name() + \"$\");\n            if (repo.value().contains(\"branches\")) {\n                branchPattern = Pattern.compile(repo.value().get(\"branches\").asString());\n            }\n\n            var baseName = repo.value().contains(\"basename\") ? repo.value().get(\"basename\").asString() : configuration.repositoryName(repoName);\n\n            var tagStorageBuilder = new StorageBuilder<UpdatedTag>(baseName + \".tags.txt\")\n                    .remoteRepository(databaseRepo, databaseRef, databaseName, databaseEmail, \"Added tag for \" + repoName);\n            var branchStorageBuilder = new StorageBuilder<UpdatedBranch>(baseName + \".branches.txt\")\n                    .remoteRepository(databaseRepo, databaseRef, databaseName, databaseEmail, \"Added branch hash for \" + repoName);\n            var prStateStorageBuilder = new StorageBuilder<PullRequestState>(baseName + \".prissues.txt\")\n                    .remoteRepository(databaseRepo, databaseRef, databaseName, databaseEmail, \"Added pull request issue info for \" + repoName);\n            var bot = NotifyBot.newBuilder()\n                               .repository(configuration.repository(repoName))\n                               .storagePath(configuration.storageFolder())\n                               .branches(branchPattern)\n                               .tagStorageBuilder(tagStorageBuilder)\n                               .branchStorageBuilder(branchStorageBuilder)\n                               .prStateStorageBuilder(prStateStorageBuilder)\n                               .readyComments(readyComments)\n                               .integratorId(integratorId)\n                               .build();\n\n            var hasAttachedNotifier = false;\n            for (var notifierFactory : notifierFactories) {\n                if (repo.value().contains(notifierFactory.name())) {\n                    var confArray = repo.value().get(notifierFactory.name());\n                    if (!confArray.isArray()) {\n                        confArray = JSON.array().add(confArray);\n                    }\n                    for (var conf : confArray.asArray()) {\n                        var finalConfiguration = combineConfiguration(notifierConfiguration.get(notifierFactory.name()), conf.asObject());\n                        var notifier = Notifier.create(notifierFactory.name(), configuration, finalConfiguration);\n                        log.info(\"Configuring notifier \" + notifierFactory.name() + \" for repository \" + repoName);\n                        notifier.attachTo(bot);\n                        hasAttachedNotifier = true;\n                    }\n                }\n            }\n\n            if (!hasAttachedNotifier) {\n                log.warning(\"No notifiers configured for notify bot repository: \" + repoName);\n                continue;\n            }\n\n            ret.add(bot);\n        }\n\n        return ret;\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/PullRequestListener.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.openjdk.Issue;\n\nimport java.nio.file.Path;\n\npublic interface PullRequestListener {\n    default void onNewIssue(PullRequest pr, Path scratchPath, Issue issue) {\n    }\n    default void onRemovedIssue(PullRequest pr, Path scratchPath, Issue issue) {\n    }\n    default void onNewPullRequest(PullRequest pr, Path scratchPath) {\n    }\n    default void onIntegratedPullRequest(PullRequest pr, Path scratchPath, Hash hash) {\n    }\n    default void onHeadChange(PullRequest pr, Path scratchPath, Hash oldHead) {\n    }\n    default void onStateChange(PullRequest pr, Path scratchPath, org.openjdk.skara.issuetracker.Issue.State oldState) {\n    }\n    default void onTargetBranchChange(PullRequest pr, Path scratchPath, Issue issue) {\n    }\n    String name();\n\n    default void initialize(HostedRepository repo) {\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/PullRequestState.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.util.*;\n\nclass PullRequestState {\n    private final String prId;\n    private final Set<String> issueIds;\n    private final Hash commitId;\n    private final Hash head;\n    private final Issue.State state;\n    private final String targetBranch;\n\n    PullRequestState(PullRequest pr, Set<String> issueIds, Hash commitId, Hash head, Issue.State state) {\n        this.prId = pr.repository().id() + \":\" + pr.id();\n        this.issueIds = issueIds;\n        this.commitId = commitId;\n        this.head = head;\n        this.state = state;\n        this.targetBranch = pr.targetRef();\n    }\n\n    PullRequestState(String prId, Set<String> issueIds, Hash commitId, Hash head, Issue.State state, String targetBranch) {\n        this.prId = prId;\n        this.issueIds = issueIds;\n        this.commitId = commitId;\n        this.head = head;\n        this.state = state;\n        this.targetBranch = targetBranch;\n    }\n\n    public String prId() {\n        return prId;\n    }\n\n    public Set<String> issueIds() {\n        return issueIds;\n    }\n\n    public Optional<Hash> commitId() {\n        return Optional.ofNullable(commitId);\n    }\n\n    public Hash head() {\n        return head;\n    }\n\n    public Issue.State state() {\n        return state;\n    }\n\n    public String targetBranch() {\n        return targetBranch;\n    }\n\n    @Override\n    public String toString() {\n        return \"PullRequestState{\" +\n                \"prId='\" + prId + '\\'' +\n                \", issueIds=\" + issueIds +\n                \", commitId=\" + commitId +\n                \", head=\" + head +\n                \", state=\" + state +\n                \", targetBranch=\" + targetBranch +\n                '}';\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        var that = (PullRequestState) o;\n        return prId.equals(that.prId) &&\n                issueIds.equals(that.issueIds) &&\n                Objects.equals(commitId, that.commitId) &&\n                Objects.equals(head, that.head) &&\n                Objects.equals(state, that.state) &&\n                Objects.equals(targetBranch, that.targetBranch);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(prId, issueIds, commitId, head, targetBranch);\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/PullRequestWorkItem.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport java.util.logging.Level;\nimport java.util.logging.Logger;\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.bots.common.BotUtils;\nimport org.openjdk.skara.bots.notify.prbranch.PullRequestBranchNotifier;\nimport org.openjdk.skara.forge.PreIntegrations;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.storage.StorageBuilder;\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.openjdk.Issue;\n\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.function.Consumer;\nimport java.util.regex.*;\nimport java.util.stream.*;\n\nimport static org.openjdk.skara.bots.common.PullRequestConstants.*;\n\npublic class PullRequestWorkItem implements WorkItem {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.notify\");\n    private final PullRequest pr;\n    private final StorageBuilder<PullRequestState> prStateStorageBuilder;\n    private final List<PullRequestListener> listeners;\n    private final Consumer<RuntimeException> errorHandler;\n    private final String integratorId;\n    private final Map<String, Pattern> readyComments;\n\n    PullRequestWorkItem(PullRequest pr, StorageBuilder<PullRequestState> prStateStorageBuilder,\n            List<PullRequestListener> listeners, Consumer<RuntimeException> errorHandler,\n            String integratorId, Map<String, Pattern> readyComments) {\n        this.pr = pr;\n        this.prStateStorageBuilder = prStateStorageBuilder;\n        this.listeners = listeners;\n        this.errorHandler = errorHandler;\n        this.integratorId = integratorId;\n        this.readyComments = readyComments;\n    }\n\n    private Hash resultingCommitHash() {\n        return integratorId != null ? pr.findIntegratedCommitHash(List.of(integratorId)).orElse(null) : null;\n    }\n\n    private Set<PullRequestState> deserializePrState(String current) {\n        if (current.isBlank()) {\n            return Set.of();\n        }\n        var data = JSON.parse(current);\n        return data.stream()\n                   .map(JSONValue::asObject)\n                   .map(obj -> {\n                       var id = obj.get(\"pr\").asString();\n                       var issues = obj.get(\"issues\").stream()\n                                                     .map(JSONValue::asString)\n                                                     .collect(Collectors.toSet());\n\n                       // Storage might be missing commit information\n                       if (!obj.contains(\"commit\")) {\n                           obj.put(\"commit\", Hash.zero().hex());\n                       }\n                       if (!obj.contains(\"head\")) {\n                           obj.put(\"head\", Hash.zero().hex());\n                       }\n                       if (!obj.contains(\"state\")) {\n                           obj.put(\"state\", JSON.of());\n                       }\n\n                       var commit = obj.get(\"commit\").isNull() ?\n                               null : new Hash(obj.get(\"commit\").asString());\n                       var state = obj.get(\"state\").isNull() ?\n                               null : org.openjdk.skara.issuetracker.Issue.State.valueOf(obj.get(\"state\").asString());\n                       var targetBranch = obj.get(\"targetBranch\") == null ?\n                               null : obj.get(\"targetBranch\").asString();\n\n                       return new PullRequestState(id, issues, commit, new Hash(obj.get(\"head\").asString()), state, targetBranch);\n                   })\n                .collect(Collectors.toSet());\n    }\n\n    private String serializePrState(Collection<PullRequestState> added, Set<PullRequestState> existing) {\n        var addedPrs = added.stream()\n                            .map(PullRequestState::prId)\n                            .collect(Collectors.toSet());\n        var nonReplaced = existing.stream()\n                                  .filter(item -> !addedPrs.contains(item.prId()))\n                                  .collect(Collectors.toSet());\n\n        var entries = Stream.concat(nonReplaced.stream(),\n                                    added.stream())\n                            .sorted(Comparator.comparing(PullRequestState::prId))\n                            .map(pr -> {\n                                var issues = new JSONArray(pr.issueIds()\n                                                             .stream()\n                                                             .map(JSON::of)\n                                                             .collect(Collectors.toList()));\n                                var ret = JSON.object().put(\"pr\", pr.prId())\n                                              .put(\"issues\",issues);\n                                if (pr.commitId().isPresent()) {\n                                    if (!pr.commitId().get().equals(Hash.zero())) {\n                                        ret.put(\"commit\", JSON.of(pr.commitId().get().hex()));\n                                    }\n                                } else {\n                                    ret.putNull(\"commit\");\n                                }\n                                ret.put(\"head\", JSON.of(pr.head().hex()));\n                                if (pr.state() != null) {\n                                    ret.put(\"state\", JSON.of(pr.state().toString()));\n                                } else {\n                                    ret.putNull(\"state\");\n                                }\n                                if (pr.targetBranch() != null) {\n                                    ret.put(\"targetBranch\", JSON.of(pr.targetBranch()));\n                                }\n                                return ret;\n                            })\n                            .map(JSONObject::toString)\n                            .collect(Collectors.toList());\n        return \"[\\n\" + String.join(\",\\n\", entries) + \"\\n]\";\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof PullRequestWorkItem otherItem)) {\n            return true;\n        }\n        if (!pr.isSame(otherItem.pr)) {\n            return true;\n        }\n        return false;\n    }\n\n    private void notifyNewIssue(String issueId, Path scratchPath) {\n        listeners.forEach(c -> c.onNewIssue(pr, scratchPath.resolve(c.name()), new Issue(issueId, \"\")));\n    }\n\n    private void notifyRemovedIssue(String issueId, Path scratchPath) {\n        listeners.forEach(c -> c.onRemovedIssue(pr, scratchPath.resolve(c.name()), new Issue(issueId, \"\")));\n    }\n\n    private void notifyNewPr(PullRequest pr, Path scratchPath) {\n        listeners.forEach(c -> c.onNewPullRequest(pr, scratchPath.resolve(c.name())));\n    }\n\n    private void notifyIntegratedPr(PullRequest pr, Hash hash, Path scratchPath) {\n        listeners.forEach(c -> c.onIntegratedPullRequest(pr, scratchPath.resolve(c.name()), hash));\n    }\n\n    private void notifyHeadChange(PullRequest pr, Hash oldHead, Path scratchPath) {\n        listeners.forEach(c -> c.onHeadChange(pr, scratchPath.resolve(c.name()), oldHead));\n    }\n\n    private void notifyStateChange(org.openjdk.skara.issuetracker.Issue.State oldState, Path scratchPath) {\n        listeners.forEach(c -> c.onStateChange(pr, scratchPath.resolve(c.name()), oldState));\n    }\n\n    private void notifyTargetBranchChange(String issueId, Path scratchPath) {\n        listeners.forEach(c -> c.onTargetBranchChange(pr, scratchPath.resolve(c.name()), new Issue(issueId, \"\")));\n    }\n\n    private boolean isOfInterest(PullRequest pr) {\n        var labels = new HashSet<>(pr.labelNames());\n        if (!(labels.contains(\"rfr\") || labels.contains(\"integrated\"))) {\n            // If the PullRequestBranchNotifier is configured, check for the existence of\n            // a pre-integration branch as that may need to be removed by the listener\n            // even if none of the labels match.\n            var prBranchListenerExists = listeners.stream()\n                    .anyMatch(l -> l instanceof PullRequestBranchNotifier);\n            var branchExists = prBranchListenerExists && pr.repository().branchHash(PreIntegrations.preIntegrateBranch(pr)).isPresent();\n            if (!branchExists) {\n                log.fine(\"PR is not yet ready - needs either 'rfr' or 'integrated' label, or a pre-integration branch present\");\n                return false;\n            }\n        }\n\n        var comments = pr.comments();\n        for (var readyComment : readyComments.entrySet()) {\n            var commentFound = false;\n            for (var comment : comments) {\n                if (comment.author().username().equals(readyComment.getKey())) {\n                    var matcher = readyComment.getValue().matcher(comment.body());\n                    if (matcher.find()) {\n                        commentFound = true;\n                        break;\n                    }\n                }\n            }\n            if (!commentFound) {\n                log.fine(\"PR is not yet ready - missing ready comment from '\" + readyComment.getKey() +\n                        \"containing '\" + readyComment.getValue().pattern() + \"'\");\n                return false;\n            }\n        }\n        return true;\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        if (!isOfInterest(pr)) {\n            return List.of();\n        }\n        if (pr.isOpen() && pr.body().contains(TEMPORARY_ISSUE_FAILURE_MARKER)) {\n            log.warning(\"Found temporary issue failure, the notifiers will be stopped until the temporary issue failure resolved.\");\n            return List.of();\n        }\n        var historyPath = scratchPath.resolve(\"notify\").resolve(\"history\").resolve(\"pr\");\n        var listenerScratchPath = scratchPath.resolve(\"notify\").resolve(\"listener\");\n        var storage = prStateStorageBuilder\n                .serializer(this::serializePrState)\n                .deserializer(this::deserializePrState)\n                .materialize(historyPath);\n\n        var issues = BotUtils.parseIssues(pr.body());\n        var commit = resultingCommitHash();\n        var state = new PullRequestState(pr, issues, commit, pr.headHash(), pr.state());\n        var stored = storage.current();\n        if (stored.contains(state)) {\n            // Already up to date\n            return List.of();\n        }\n\n        // Search for an existing\n        var storedState = stored.stream()\n                .filter(ss -> ss.prId().equals(state.prId()))\n                .findAny();\n        // The stored entry could be old and be missing commit information - if so, upgrade it\n        if (storedState.isPresent()) {\n            if (storedState.get().commitId().equals(Optional.of(Hash.zero()))) {\n                var hash = resultingCommitHash();\n                storedState = Optional.of(new PullRequestState(pr, storedState.get().issueIds(), hash, pr.headHash(), pr.state()));\n                storage.put(storedState.get());\n            }\n            if (storedState.get().head().equals(Hash.zero())) {\n                storedState = Optional.of(new PullRequestState(pr, storedState.get().issueIds(), storedState.get().commitId().orElse(null), pr.headHash(), pr.state()));\n                storage.put(storedState.get());\n            }\n            if (storedState.get().state() == null) {\n                storedState = Optional.of(new PullRequestState(pr, storedState.get().issueIds(), storedState.get().commitId().orElse(null), pr.headHash(), pr.state()));\n                storage.put(storedState.get());\n            }\n        }\n\n        if (storedState.isPresent()) {\n            var storedIssues = storedState.get().issueIds();\n            storedIssues.stream()\n                        .filter(issue -> !issues.contains(issue))\n                        .forEach(issue -> notifyRemovedIssue(issue, listenerScratchPath));\n            issues.stream()\n                  .filter(issue -> !storedIssues.contains(issue))\n                  .forEach(issue -> notifyNewIssue(issue, listenerScratchPath));\n\n            if (!storedState.get().head().equals(state.head())) {\n                notifyHeadChange(pr, storedState.get().head(), listenerScratchPath);\n            }\n            var storedCommit = storedState.get().commitId();\n            if (storedCommit.isEmpty() && state.commitId().isPresent()) {\n                notifyIntegratedPr(pr, state.commitId().get(), listenerScratchPath);\n            }\n            if (!storedState.get().state().equals(state.state())) {\n                notifyStateChange(storedState.get().state(), scratchPath);\n            }\n            var storedTargetBranch = storedState.get().targetBranch();\n            if (state.targetBranch() != null && !state.targetBranch().equals(storedTargetBranch)) {\n                storedIssues.stream()\n                        .filter(issues::contains)\n                        .forEach(issue -> notifyTargetBranchChange(issue, listenerScratchPath));\n            }\n        } else {\n            notifyNewPr(pr, listenerScratchPath);\n            issues.forEach(issue -> notifyNewIssue(issue, listenerScratchPath));\n            if (state.commitId().isPresent()) {\n                notifyIntegratedPr(pr, state.commitId().get(), listenerScratchPath);\n            }\n        }\n\n        storage.put(state);\n        // This is mixing timestamps from the forge and the local host, which may not produce\n        // very accurate latencies, but it's the best we can do for this bot.\n        var latency = Duration.between(pr.updatedAt(), ZonedDateTime.now());\n        log.log(Level.INFO, \"Time from PR updated to notifications done \" + latency, latency);\n        return List.of();\n    }\n\n    @Override\n    public String toString() {\n        return \"Notify.PR@\" + pr.repository().name() + \"#\" + pr.id();\n    }\n\n    @Override\n    public void handleRuntimeException(RuntimeException e) {\n        errorHandler.accept(e);\n    }\n\n    @Override\n    public String botName() {\n        return NotifyBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return \"pr\";\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/RepositoryListener.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.OpenJDKTag;\n\nimport java.nio.file.Path;\nimport java.util.List;\n\npublic interface RepositoryListener {\n    default void onNewCommits(HostedRepository repository, Repository localRepository, Path scratchPath, List<Commit> commits, Branch branch) throws NonRetriableException {\n    }\n    default void onNewOpenJDKTagCommits(HostedRepository repository, Repository localRepository, Path scratchPath, List<Commit> commits, OpenJDKTag tag, Tag.Annotated annotated) throws NonRetriableException {\n    }\n    default void onNewTagCommit(HostedRepository repository, Repository localRepository, Path scratchPath, Commit commit, Tag tag, Tag.Annotated annotation) throws NonRetriableException {\n    }\n    default void onNewBranch(HostedRepository repository, Repository localRepository, Path scratchPath, List<Commit> commits, Branch parent, Branch branch) throws NonRetriableException {\n    }\n    String name();\n\n    /**\n     * Returns true if this listener can handle being called with the same\n     * data multiple times without generating multiple notifications\n     */\n    boolean idempotent();\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/RepositoryWorkItem.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.storage.StorageBuilder;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.OpenJDKTag;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.logging.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.*;\n\n/**\n * A RepositoryWorkItem acts on changes to a particular repository. Multiple kinds\n * of listeners can be notified about various types of changes. Some listeners can\n * handle being called multiple times with the same update while others cannot. To\n * avoid sending multiple emails or slack messages, we will never call such\n * listeners multiple times with the same update.\n *\n * This is achieved with a combination of declaring a listener idempotent and\n * throwing NonRetriableException. For a listener that is declared idempotent, we\n * will not update the history repository until after a successful notification,\n * which means the bot will retry until successful. For a listener that is not\n * idempotent, we will update the history repo before attempting to notify the\n * listener. If the listener fails without throwing NonRetriableException, we will\n * attempt a rollback of the history repo, which means the bot will likely retry\n * in the future, but it is not guaranteed as the bot could be killed at any time\n * and the state could then be lost.\n */\npublic class RepositoryWorkItem implements WorkItem {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n    private final HostedRepository repository;\n    private final Path storagePath;\n    private final Pattern branches;\n    private final StorageBuilder<UpdatedTag> tagStorageBuilder;\n    private final StorageBuilder<UpdatedBranch> branchStorageBuilder;\n    private final List<RepositoryListener> listeners;\n\n    private static final int NEW_REPOSITORY_COMMIT_THRESHOLD = 5;\n\n    RepositoryWorkItem(HostedRepository repository, Path storagePath, Pattern branches, StorageBuilder<UpdatedTag> tagStorageBuilder, StorageBuilder<UpdatedBranch> branchStorageBuilder, List<RepositoryListener> listeners) {\n        this.repository = repository;\n        this.storagePath = storagePath;\n        this.branches = branches;\n        this.tagStorageBuilder = tagStorageBuilder;\n        this.branchStorageBuilder = branchStorageBuilder;\n        this.listeners = listeners;\n    }\n\n    private void handleNewRef(Repository localRepo, Reference ref, Collection<Reference> candidateRefs,\n                              RepositoryListener listener, Path scratchPath) throws NonRetriableException {\n        // Figure out the best parent ref\n        var candidates = new HashSet<>(candidateRefs);\n        candidates.remove(ref);\n        if (candidates.size() == 0) {\n            log.warning(\"No parent candidates found for branch '\" + ref.name() + \"' - ignoring\");\n            return;\n        }\n\n        var bestParent = candidates.stream()\n                                   .map(c -> {\n                                       try {\n                                           return new AbstractMap.SimpleEntry<>(c, localRepo.commitMetadata(c.hash().hex() + \"..\" + ref.hash()));\n                                       } catch (IOException e) {\n                                           throw new UncheckedIOException(e);\n                                       }\n                                   })\n                                   .min(Comparator.comparingInt(entry -> entry.getValue().size()))\n                                   .orElseThrow();\n        if (bestParent.getValue().size() > 1000) {\n            throw new RuntimeException(\"Excessive amount of unique commits on new branch \" + ref.name() +\n                                               \" detected (\" + bestParent.getValue().size() + \") - skipping notifications\");\n        }\n        List<Commit> bestParentCommits;\n        try {\n            bestParentCommits = localRepo.commits(bestParent.getKey().hash().hex() + \"..\" + ref.hash(), true).asList();\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        var branch = new Branch(ref.name());\n        var parent = new Branch(bestParent.getKey().name());\n        listener.onNewBranch(repository, localRepo, scratchPath, bestParentCommits, parent, branch);\n    }\n\n    private void handleUpdatedRef(Repository localRepo, Reference ref, List<Commit> commits, RepositoryListener listener, Path scratchPath) throws NonRetriableException {\n        var branch = new Branch(ref.name());\n        listener.onNewCommits(repository, localRepo, scratchPath, commits, branch);\n    }\n\n    private List<Throwable> handleRef(Repository localRepo, UpdateHistory history, Reference ref,\n                                      Collection<Reference> candidateRefs, Path scratchPath) throws IOException {\n        var errors = new ArrayList<Throwable>();\n        var branch = new Branch(ref.name());\n        for (var listener : listeners) {\n            var lastHash = history.branchHash(branch, listener.name());\n            if (lastHash.isEmpty()) {\n                log.warning(\"No previous history found for branch '\" + branch + \"' and listener '\" + listener.name() + \" - resetting mark\");\n                if (!listener.idempotent()) {\n                    history.setBranchHash(branch, listener.name(), ref.hash());\n                }\n                try {\n                    handleNewRef(localRepo, ref, candidateRefs, listener, scratchPath.resolve(listener.name()));\n                } catch (NonRetriableException e) {\n                    errors.add(e.cause());\n                    continue;\n                } catch (RuntimeException e) {\n                    // FIXME: Attempt rollback? No current listener that would use it\n                    errors.add(e);\n                    continue;\n                }\n                if (listener.idempotent()) {\n                    history.setBranchHash(branch, listener.name(), ref.hash());\n                }\n            } else {\n                var commitMetadata = localRepo.commitMetadata(lastHash.get() + \"..\" + ref.hash());\n                if (commitMetadata.size() == 0) {\n                    continue;\n                }\n                if (commitMetadata.size() > 1000) {\n                    history.setBranchHash(branch, listener.name(), ref.hash());\n                    errors.add(new RuntimeException(\"Excessive amount of new commits on branch \" + branch.name() +\n                                                       \" detected (\" + commitMetadata.size() + \") for listener '\" +\n                                                       listener.name() + \"' - skipping notifications\"));\n                    continue;\n                }\n\n                var commits = localRepo.commits(lastHash.get() + \"..\" + ref.hash(), true).asList();\n                if (!listener.idempotent()) {\n                    history.setBranchHash(branch, listener.name(), ref.hash());\n                }\n                try {\n                    handleUpdatedRef(localRepo, ref, commits, listener, scratchPath.resolve(listener.name()));\n                    if (log.isLoggable(Level.INFO)) {\n                        var now = ZonedDateTime.now();\n                        for (Commit commit : commits) {\n                            var latency = Duration.between(commit.metadata().committed(), now);\n                            log.log(Level.INFO, \"Time from committed to notified for \" + commit.hash()\n                                    + \" on branch \" + ref + \" \" + latency, latency);\n                        }\n                    }\n                } catch (NonRetriableException e) {\n                    log.log(Level.INFO, \"Non retriable exception occurred\", e);\n                    errors.add(e.cause());\n                    continue;\n                } catch (RuntimeException e) {\n                    // Attempt to roll back\n                    if (!listener.idempotent()) {\n                        log.log(Level.INFO, \"Retriable exception occurred\", e);\n                        history.setBranchHash(branch, listener.name(), lastHash.get());\n                    }\n                    errors.add(e);\n                    continue;\n                }\n                if (listener.idempotent()) {\n                    history.setBranchHash(branch, listener.name(), ref.hash());\n                }\n            }\n        }\n        return errors;\n    }\n\n    private Optional<OpenJDKTag> existingPrevious(OpenJDKTag tag, Set<OpenJDKTag> allJdkTags) {\n        while (true) {\n            var candidate = tag.previous();\n            if (candidate.isEmpty()) {\n                return Optional.empty();\n            }\n            tag = candidate.get();\n            if (!allJdkTags.contains(tag)) {\n                continue;\n            }\n            return Optional.of(tag);\n        }\n    }\n\n    private List<Throwable> handleTags(Repository localRepo, UpdateHistory history, RepositoryListener listener, Path scratchPath) throws IOException {\n        var errors = new ArrayList<Throwable>();\n        var tags = localRepo.tags();\n        var newTags = tags.stream()\n                          .filter(tag -> !history.hasTag(tag, listener.name()) || history.shouldRetryTagUpdate(tag, listener.name()))\n                          .collect(Collectors.toList());\n\n        if (tags.size() == newTags.size()) {\n            if (tags.size() > 0) {\n                log.warning(\"No previous tag history found - ignoring all current tags\");\n                history.addTags(tags, listener.name());\n            }\n            return errors;\n        }\n\n        if (newTags.size() > 10) {\n            history.addTags(newTags, listener.name());\n            errors.add(new RuntimeException(\"Excessive amount of new tags detected (\" + newTags.size() +\n                                               \") - skipping notifications\"));\n            return errors;\n        }\n\n        // Filter for tags that appear in non pr-branches\n        var branches = repository.branches();\n        newTags = newTags.stream()\n                .filter(tag -> tagInNonPrBranch(tag, branches, localRepo))\n                .toList();\n\n        var allJdkTags = tags.stream()\n                             .map(OpenJDKTag::create)\n                             .filter(Optional::isPresent)\n                             .map(Optional::get)\n                             .collect(Collectors.toSet());\n        var newJdkTags = newTags.stream()\n                                .map(OpenJDKTag::create)\n                                .filter(Optional::isPresent)\n                                .map(Optional::get)\n                                .sorted(Comparator.comparingInt(tag -> tag.buildNum().orElse(-1)))\n                                .collect(Collectors.toList());\n        for (var tag : newJdkTags) {\n            var commits = new ArrayList<Commit>();\n\n            // Try to determine which commits are new since the last build\n            var previous = existingPrevious(tag, allJdkTags);\n            if (previous.isPresent()) {\n                commits.addAll(localRepo.commits(previous.get().tag() + \"..\" + tag.tag()).asList());\n            }\n\n            // If none are found, just include the commit that was tagged\n            if (commits.isEmpty()) {\n                var commit = localRepo.lookup(tag.tag());\n                if (commit.isEmpty()) {\n                    throw new RuntimeException(\"Failed to lookup tag '\" + tag.toString() + \"'\");\n                } else {\n                    commits.add(commit.get());\n                }\n            }\n\n            Collections.reverse(commits);\n            var annotation = localRepo.annotate(tag.tag());\n\n            if (!listener.idempotent()) {\n                history.addTags(List.of(tag.tag()), listener.name());\n            }\n            try {\n                listener.onNewOpenJDKTagCommits(repository, localRepo, scratchPath, commits, tag, annotation.orElse(null));\n            } catch (NonRetriableException e) {\n                errors.add(e.cause());\n                continue;\n            } catch (RuntimeException e) {\n                errors.add(e);\n                if (!listener.idempotent()) {\n                    history.retryTagUpdate(tag.tag(), listener.name());\n                }\n                continue;\n            }\n            if (listener.idempotent()) {\n                history.addTags(List.of(tag.tag()), listener.name());\n            }\n        }\n\n        var newNonJdkTags = newTags.stream()\n                                   .filter(tag -> OpenJDKTag.create(tag).isEmpty())\n                                   .collect(Collectors.toList());\n        for (var tag : newNonJdkTags) {\n            var commit = localRepo.lookup(tag);\n            if (commit.isEmpty()) {\n                throw new RuntimeException(\"Failed to lookup tag '\" + tag.toString() + \"'\");\n            }\n\n            var annotation = localRepo.annotate(tag);\n\n            if (!listener.idempotent()) {\n                history.addTags(List.of(tag), listener.name());\n            }\n            try {\n                listener.onNewTagCommit(repository, localRepo, scratchPath, commit.get(), tag, annotation.orElse(null));\n            } catch (NonRetriableException e) {\n                errors.add(e.cause());\n                continue;\n            } catch (RuntimeException e) {\n                errors.add(e);\n                if (!listener.idempotent()) {\n                    history.retryTagUpdate(tag, listener.name());\n                }\n                continue;\n            }\n            if (listener.idempotent()) {\n                history.addTags(List.of(tag), listener.name());\n            }\n        }\n\n        return errors;\n    }\n\n    private boolean tagInNonPrBranch(Tag tag, List<HostedBranch> branches, Repository localRepository) {\n        try {\n            for (var branch : branches) {\n                if (!PreIntegrations.isPreintegrationBranch(branch.name())) {\n                    var hash = localRepository.resolve(tag).orElseThrow();\n                    if (localRepository.isAncestor(hash, branch.hash())) {\n                        return true;\n                    }\n                }\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        return false;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof RepositoryWorkItem otherItem)) {\n            return true;\n        }\n        if (!repository.name().equals(otherItem.repository.name())) {\n            return true;\n        }\n        return false;\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        var historyPath = scratchPath.resolve(\"notify\").resolve(\"history\");\n        var repositoryPool = new HostedRepositoryPool(storagePath.resolve(\"seeds\"));\n        var notifierScratchPath = scratchPath.resolve(\"notify\").resolve(\"notifier\");\n\n        try {\n            var localRepo = repositoryPool.materializeBare(repository, scratchPath.resolve(\"notify\").resolve(\"repowi\").resolve(repository.name()));\n            var defaultBranchName = localRepo.defaultBranch().name();\n            // All the branches can be candidate branches except pr/X branches\n            var candidateRefs = localRepo.remoteBranches(repository.authenticatedUrl().toString())\n                    .stream()\n                    .filter(ref -> !ref.name().startsWith(\"pr/\"))\n                    .toList();\n            var knownRefs = candidateRefs\n                    .stream()\n                    .filter(ref -> branches.matcher(ref.name()).matches())\n                    .toList();\n            localRepo.fetchAll(repository.authenticatedUrl(), true);\n\n            var history = UpdateHistory.create(tagStorageBuilder, historyPath.resolve(\"tags\"), branchStorageBuilder, historyPath.resolve(\"branches\"));\n            var errors = new ArrayList<Throwable>();\n\n            boolean hasBranchHistory = !history.isEmpty();\n            for (var ref : knownRefs) {\n                if (!hasBranchHistory) {\n                    log.warning(\"No previous history found for any branch - resetting mark for '\" + ref.name());\n                    if (localRepo.commitCount() <= NEW_REPOSITORY_COMMIT_THRESHOLD) {\n                        log.info(\"This is a new repo, starting notifications from the very first commit\");\n                        for (var listener : listeners) {\n                            log.info(\"Resetting mark for branch '\" + ref.name() + \"' for listener '\" + listener.name() + \"'\");\n                            // Initialize the mark for the branches with special Git empty tree hash to trigger notifications on all existing commits.\n                            history.setBranchHash(new Branch(ref.name()), listener.name(), localRepo.initialHash());\n                        }\n                    } else {\n                        log.info(\"This is an existing repo with history, starting notifications from commits after \" + ref.hash());\n                        for (var listener : listeners) {\n                            log.info(\"Resetting mark for branch '\" + ref.name() + \"' for listener '\" + listener.name() + \"'\");\n                            // Initialize the mark for the branches with the current HEAD hash. Notifications will start on future commits.\n                            history.setBranchHash(new Branch(ref.name()), listener.name(), ref.hash());\n                        }\n                    }\n                }\n                errors.addAll(handleRef(localRepo, history, ref, candidateRefs, scratchPath));\n            }\n\n            for (var listener : listeners) {\n                errors.addAll(handleTags(localRepo, history, listener, notifierScratchPath.resolve(listener.name())));\n            }\n\n            if (!errors.isEmpty()) {\n                errors.forEach(error -> log.log(Level.WARNING, error.getMessage(), error));\n                throw new RuntimeException(\"Errors detected when processing repository notifications\", errors.get(0));\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        return List.of();\n    }\n\n    @Override\n    public String toString() {\n        return \"RepositoryWorkItem@\" + repository.name();\n    }\n\n    @Override\n    public String botName() {\n        return NotifyBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return \"repository\";\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/UpdateHistory.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.openjdk.skara.storage.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.function.Function;\nimport java.util.stream.*;\n\nclass UpdateHistory {\n    private final Storage<UpdatedTag> tagStorage;\n    private final Storage<UpdatedBranch> branchStorage;\n\n    private Map<String, Hash> branchHashes;\n    private Map<String, Boolean> tagRetries;\n\n    private List<UpdatedBranch> parseSerializedBranch(String entry) {\n        var parts = entry.split(\" \");\n        if (parts.length == 2) {\n            // Transform legacy entry\n            var issueEntry = new UpdatedBranch(new Branch(parts[0]), \"issue\", new Hash(parts[1]));\n            var mlEntry = new UpdatedBranch(new Branch(parts[0]), \"ml\", new Hash(parts[1]));\n            return List.of(issueEntry, mlEntry);\n        }\n        return List.of(new UpdatedBranch(new Branch(parts[0]), parts[1], new Hash(parts[2])));\n    }\n\n    private Set<UpdatedBranch> loadBranches(String current) {\n        return current.lines()\n                      .flatMap(line -> parseSerializedBranch(line).stream())\n                      .collect(Collectors.toSet());\n    }\n\n    private String serializeBranch(UpdatedBranch entry) {\n        return entry.branch().toString() + \" \" + entry.updater() + \" \" + entry.hash().toString();\n    }\n\n    private String serializeBranches(Collection<UpdatedBranch> added, Set<UpdatedBranch> existing) {\n        var updatedBranches = existing.stream()\n                                      .collect(Collectors.toMap(entry -> entry.branch().toString() + \":\" + entry.updater(),\n                                                                Function.identity()));\n        added.forEach(a -> updatedBranches.put(a.branch().toString() + \":\" + a.updater(), a));\n        return updatedBranches.values().stream()\n                              .map(this::serializeBranch)\n                              .sorted()\n                              .collect(Collectors.joining(\"\\n\"));\n    }\n\n    private List<UpdatedTag> parseSerializedTag(String entry) {\n        var parts = entry.split(\" \");\n        if (parts.length == 1) {\n            // Transform legacy entry\n            var issueEntry = new UpdatedTag(new Tag(entry), \"issue\", false);\n            var mlEntry = new UpdatedTag(new Tag(entry), \"ml\", false);\n            return List.of(issueEntry, mlEntry);\n        }\n        return List.of(new UpdatedTag(new Tag(parts[0]), parts[1], parts[2].equals(\"retry\")));\n    }\n\n    private Set<UpdatedTag> loadTags(String current) {\n        return current.lines()\n                      .flatMap(line -> parseSerializedTag(line).stream())\n                      .collect(Collectors.toSet());\n    }\n\n    private String serializeTag(UpdatedTag entry) {\n        return entry.tag().toString() + \" \" + entry.updater() + \" \" + (entry.shouldRetry() ? \"retry\" : \"done\");\n    }\n\n    private String serializeTags(Collection<UpdatedTag> added, Set<UpdatedTag> existing) {\n        var updatedTags = existing.stream()\n                                  .collect(Collectors.toMap(entry -> entry.tag().toString() + \":\" + entry.updater(),\n                                                            Function.identity()));\n        added.forEach(a -> updatedTags.put(a.tag().toString() + \":\" + a.updater(), a));\n        return updatedTags.values().stream()\n                          .map(this::serializeTag)\n                          .sorted()\n                          .collect(Collectors.joining(\"\\n\"));\n    }\n\n    private Map<String, Hash> currentBranchHashes() {\n        return branchStorage.current().stream()\n                            .collect(Collectors.toMap(rb -> rb.branch().toString() + \" \" + rb.updater(), UpdatedBranch::hash));\n    }\n\n    private Map<String, Boolean> currentTags() {\n        return tagStorage.current().stream()\n                         .collect(Collectors.toMap(u -> u.tag().toString() + \" \" + u.updater(), UpdatedTag::shouldRetry));\n    }\n\n    private UpdateHistory(StorageBuilder<UpdatedTag> tagStorageBuilder, Path tagLocation, StorageBuilder<UpdatedBranch> branchStorageBuilder, Path branchLocation) {\n        this.tagStorage = tagStorageBuilder\n                .serializer(this::serializeTags)\n                .deserializer(this::loadTags)\n                .materialize(tagLocation);\n\n        this.branchStorage = branchStorageBuilder\n                .serializer(this::serializeBranches)\n                .deserializer(this::loadBranches)\n                .materialize(branchLocation);\n\n        tagRetries = currentTags();\n        branchHashes = currentBranchHashes();\n    }\n\n    static UpdateHistory create(StorageBuilder<UpdatedTag> tagStorageBuilder, Path tagLocation, StorageBuilder<UpdatedBranch> branchStorageBuilder, Path branchLocation) {\n        return new UpdateHistory(tagStorageBuilder, tagLocation, branchStorageBuilder, branchLocation);\n    }\n\n    void addTags(Collection<Tag> addedTags, String updater) {\n        var newEntries = addedTags.stream()\n                                  .map(t -> new UpdatedTag(t, updater, false))\n                                  .collect(Collectors.toSet());\n        tagStorage.put(newEntries);\n        tagRetries = currentTags();\n    }\n\n    void retryTagUpdate(Tag tagToRetry, String updater) {\n        var entry = new UpdatedTag(tagToRetry, updater, true);\n        tagStorage.put(List.of(entry));\n        tagRetries = currentTags();\n    }\n\n    boolean hasTag(Tag tag, String updater) {\n        return tagRetries.containsKey(tag.toString() + \" \" + updater);\n    }\n\n    boolean shouldRetryTagUpdate(Tag tag, String updater) {\n        return tagRetries.getOrDefault(tag.toString() + \" \" + updater, false);\n    }\n\n    void setBranchHash(Branch branch, String updater, Hash hash) {\n        var entry = new UpdatedBranch(branch, updater, hash);\n\n        branchStorage.put(entry);\n        branchHashes = currentBranchHashes();\n    }\n\n    Optional<Hash> branchHash(Branch branch, String updater) {\n        var entry = branchHashes.get(branch.toString() + \" \" + updater);\n        return Optional.ofNullable(entry);\n    }\n\n    boolean isEmpty() {\n        return branchHashes.isEmpty();\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/UpdatedBranch.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.openjdk.skara.vcs.*;\n\nimport java.util.Objects;\n\npublic class UpdatedBranch {\n    private final Branch branch;\n    private final String updater;\n    private final Hash hash;\n\n    UpdatedBranch(Branch branch, String updater, Hash hash) {\n        this.branch = branch;\n        this.updater = updater;\n        this.hash = hash;\n    }\n\n    public Branch branch() {\n        return branch;\n    }\n\n    public String updater() {\n        return updater;\n    }\n\n    public Hash hash() {\n        return hash;\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        UpdatedBranch that = (UpdatedBranch) o;\n        return branch.equals(that.branch) && updater.equals(that.updater) && hash.equals(that.hash);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(branch, updater, hash);\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/UpdatedTag.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.openjdk.skara.vcs.Tag;\n\nimport java.util.Objects;\n\npublic class UpdatedTag {\n    private final Tag tag;\n    private final String updater;\n    private final boolean shouldRetry;\n\n    public UpdatedTag(Tag tag, String updater, boolean shouldRetry) {\n        this.tag = tag;\n        this.updater = updater;\n        this.shouldRetry = shouldRetry;\n    }\n\n    public Tag tag() {\n        return tag;\n    }\n\n    public String updater() {\n        return updater;\n    }\n\n    public boolean shouldRetry() {\n        return shouldRetry;\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) return true;\n        if (o == null || getClass() != o.getClass()) return false;\n        UpdatedTag that = (UpdatedTag) o;\n        return shouldRetry == that.shouldRetry &&\n                tag.equals(that.tag) &&\n                updater.equals(that.updater);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(tag, updater, shouldRetry);\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/comment/CommitCommentNotifier.java",
    "content": "/*\n * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.comment;\n\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\nclass CommitCommentNotifier implements Notifier, PullRequestListener {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.notify\");\n\n    private final IssueProject issueProject;\n\n    CommitCommentNotifier(IssueProject issueProject) {\n        this.issueProject = issueProject;\n    }\n\n    private List<IssueTrackerIssue> issues(CommitMetadata metadata) {\n        var commitMessage = CommitMessageParsers.v1.parse(metadata);\n        return commitMessage.issues()\n                            .stream()\n                            .map(i -> issueProject.issue(i.shortId()))\n                            .filter(Optional::isPresent)\n                            .map(Optional::get)\n                            .collect(Collectors.toList());\n    }\n\n    @Override\n    public void attachTo(Emitter e) {\n        e.registerPullRequestListener(this);\n    }\n\n    @Override\n    public void onIntegratedPullRequest(PullRequest pr, Path scratchPath, Hash hash)  {\n        var repository = pr.repository();\n        var commit = repository.commit(hash).orElseThrow(() ->\n                new IllegalStateException(\"Integrated commit \" + hash +\n                                          \" not present in repository \" + repository.webUrl())\n        );\n        var comment = new ArrayList<String>();\n        comment.addAll(List.of(\n            \"<!-- COMMIT COMMENT NOTIFICATION -->\",\n            \"### Review\",\n            \"\",\n            \"- [\" + pr.repository().name() + \"/\" + pr.id() + \"](\" + pr.webUrl() + \")\"\n        ));\n        var issues = issues(commit.metadata());\n        if (issues.size() > 0) {\n            comment.add(\"\");\n            comment.add(\"### Issues\");\n            comment.add(\"\");\n            for (var issue : issues) {\n                comment.add(\"- [\" + issue.id() + \"](\" + issue.webUrl() + \")\");\n            }\n        }\n        var existingComments = repository.commitComments(hash);\n        var commentBody = String.join(\"\\n\", comment);\n        if (existingComments.stream().anyMatch(c -> c.body().equals(commentBody))) {\n            log.warning(\"Commit comment for \" + hash + \" already posted\");\n        } else {\n            log.info(\"Posting commit comment on \" + hash);\n            repository.addCommitComment(hash, commentBody);\n        }\n    }\n\n    @Override\n    public String name() {\n        return \"commitcomment\";\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/comment/CommitCommentNotifierFactory.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.comment;\n\nimport org.openjdk.skara.bot.BotConfiguration;\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.json.JSONObject;\n\nimport java.net.URI;\n\npublic class CommitCommentNotifierFactory implements NotifierFactory {\n    @Override\n    public String name() {\n        return \"comment\";\n    }\n\n    @Override\n    public Notifier create(BotConfiguration botConfiguration, JSONObject notifierConfiguration) {\n        var issueProject = botConfiguration.issueProject(notifierConfiguration.get(\"project\").asString());\n        return new CommitCommentNotifier(issueProject);\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/issue/CensusInstance.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.issue;\n\nimport org.openjdk.skara.census.*;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.vcs.Repository;\n\nimport java.io.*;\nimport java.net.URLEncoder;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Path;\nimport java.util.Optional;\n\nclass CensusInstance {\n    private final Namespace namespace;\n\n    private CensusInstance(Namespace namespace) {\n        this.namespace = namespace;\n    }\n\n    static CensusInstance create(HostedRepository censusRepo, String censusRef, String namespaceName) {\n        try {\n            var namespace = Census.parseNamespace(censusRepo, censusRef, namespaceName);\n            return new CensusInstance(namespace);\n        } catch (IOException e) {\n            throw new UncheckedIOException(\"Cannot parse census namespace from \" + censusRepo.name(), e);\n        }\n    }\n\n    Namespace namespace() {\n        return namespace;\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/issue/IssueNotifier.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.issue;\n\nimport java.util.regex.Pattern;\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.jbs.*;\nimport org.openjdk.skara.jcheck.JCheckConfiguration;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.network.UncheckedRestException;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.*;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.logging.Logger;\n\nimport static org.openjdk.skara.issuetracker.jira.JiraProject.RESOLVED_IN_BUILD;\n\nclass IssueNotifier implements Notifier, PullRequestListener, RepositoryListener {\n    private final IssueProject issueProject;\n    private final boolean reviewLink;\n    private final URI reviewIcon;\n    private final boolean commitLink;\n    private final URI commitIcon;\n    private final boolean setFixVersion;\n    private final LinkedHashMap<Pattern, String> fixVersions;\n    private final LinkedHashMap<Pattern, List<Pattern>> altFixVersions;\n    private final boolean prOnly;\n    private final boolean repoOnly;\n    private final String buildName;\n    private final HostedRepository censusRepository;\n    private final String censusRef;\n    private final String namespace;\n    // If true, use the version found in .jcheck/conf in the HEAD revision instead of the\n    // current commit when resolving fixVersion for a new commit.\n    private final boolean useHeadVersion;\n    // If set, use this repository for generating URLs to commits instead of the one\n    // supplied. This can be used to have the bot act on a mirror of the original\n    // repository but still generate links to the original. Only works for notifications\n    // on repository, not pull requests.\n    private final HostedRepository originalRepository;\n    // Controls whether the notifier should try to resolve issues. Only valid when\n    // pronly is true.\n    private final boolean resolve;\n\n    // A set of version opt strings that may be part of fixVersion in issues, but that\n    // do not need to be part of a tag to be considered a match.\n    private final Set<String> tagIgnoreOpt;\n\n    // Should the prefix of a tag match the prefix of a fix version to be considered\n    // a match (except for the special tag prefix 'jdk' which will always be ignored\n    // when parsing a version from a tag).\n    private final boolean tagMatchPrefix;\n\n    record BranchSecurity(Pattern branch, String securityId) {}\n    private final List<BranchSecurity> defaultSecurity;\n\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.notify\");\n\n    // Lazy loaded\n    private CensusInstance census = null;\n\n    // If true, avoid creating a \"forward backport\" when creating a new backport\n    private final boolean avoidForwardports;\n\n    // If true, allow multiple values in the Fix Versions field instead of using\n    // backport records for every additional fix version.\n    private final boolean multiFixVersions;\n\n    IssueNotifier(IssueProject issueProject, boolean reviewLink, URI reviewIcon, boolean commitLink, URI commitIcon,\n                  boolean setFixVersion, LinkedHashMap<Pattern, String> fixVersions, LinkedHashMap<Pattern, List<Pattern>> altFixVersions,\n                  boolean prOnly, boolean repoOnly, String buildName,\n                  HostedRepository censusRepository, String censusRef, String namespace, boolean useHeadVersion,\n                  HostedRepository originalRepository, boolean resolve, Set<String> tagIgnoreOpt,\n                  boolean tagMatchPrefix, List<BranchSecurity> defaultSecurity, boolean avoidForwardports,\n                  boolean multiFixVersions) {\n        this.issueProject = issueProject;\n        this.reviewLink = reviewLink;\n        this.reviewIcon = reviewIcon;\n        this.commitLink = commitLink;\n        this.commitIcon = commitIcon;\n        this.setFixVersion = setFixVersion;\n        this.fixVersions = fixVersions;\n        this.altFixVersions = altFixVersions;\n        this.prOnly = prOnly;\n        this.repoOnly = repoOnly;\n        this.buildName = buildName;\n        this.censusRepository = censusRepository;\n        this.censusRef = censusRef;\n        this.namespace = namespace;\n        this.useHeadVersion = useHeadVersion;\n        this.originalRepository = originalRepository;\n        this.resolve = resolve;\n        this.tagIgnoreOpt = tagIgnoreOpt;\n        this.tagMatchPrefix = tagMatchPrefix;\n        this.defaultSecurity = defaultSecurity;\n        this.avoidForwardports = avoidForwardports;\n        this.multiFixVersions = multiFixVersions;\n    }\n\n    static IssueNotifierBuilder newBuilder() {\n        return new IssueNotifierBuilder();\n    }\n\n    private CensusInstance getCensus() {\n        if (census == null) {\n            census = CensusInstance.create(censusRepository, censusRef, namespace);\n        }\n        return census;\n    }\n\n    private Optional<String> findCensusUser(String user, Path scratchPath) {\n        if (censusRepository == null) {\n            return Optional.empty();\n        }\n        var ns = getCensus().namespace();\n        for (var entry : ns.entries()) {\n            if (entry.getValue().username().equals(user)) {\n                return Optional.of(entry.getKey());\n            }\n        }\n        return Optional.empty();\n    }\n\n    private Optional<String> findIssueUsername(Commit commit, Path scratchPath) {\n        var authorEmail = EmailAddress.from(commit.author().email());\n        if (authorEmail.domain().equals(namespace)) {\n            return Optional.of(authorEmail.localPart());\n        } else {\n            var user = findCensusUser(authorEmail.localPart(), scratchPath);\n            if (user.isPresent()) {\n                return user;\n            }\n        }\n\n        var committerEmail = EmailAddress.from(commit.committer().email());\n        if (committerEmail.domain().equals(\"openjdk.org\")) {\n            return Optional.of(committerEmail.localPart());\n        } else {\n            var user = findCensusUser(committerEmail.localPart(), scratchPath);\n            if (user.isPresent()) {\n                return user;\n            }\n\n            log.warning(\"Cannot determine issue tracker user name from committer email: \" + committerEmail);\n            return Optional.empty();\n        }\n    }\n\n    @Override\n    public void attachTo(Emitter e) {\n        if (!repoOnly) {\n            e.registerPullRequestListener(this);\n        }\n        if (!prOnly || buildName != null) {\n            e.registerRepositoryListener(this);\n        }\n    }\n\n    @Override\n    public void onIntegratedPullRequest(PullRequest pr, Path scratchPath, Hash hash)  {\n        var repository = pr.repository();\n        var commit = repository.commit(hash).orElseThrow(() ->\n                new IllegalStateException(\"Integrated commit \" + hash +\n                                          \" not present in repository \" + repository.webUrl())\n        );\n        var commitMessage = CommitMessageParsers.v1.parse(commit);\n        for (var commitIssue : commitMessage.issues()) {\n            var optionalIssue = issueProject.issue(commitIssue.shortId());\n            if (optionalIssue.isEmpty()) {\n                log.severe(\"Cannot update issue \" + commitIssue.id() + \" with commit \" + commit.hash().abbreviate()\n                        + \" - issue not found in issue project\");\n                continue;\n            }\n            var issue = optionalIssue.get();\n\n            if (commitLink) {\n                var linkBuilder = Link.create(repository.webUrl(hash), \"Commit(\" + pr.targetRef() + \")\")\n                        .summary(repository.name() + \"/\" + hash.abbreviate());\n                if (commitIcon != null) {\n                    linkBuilder.iconTitle(\"Commit\");\n                    linkBuilder.iconUrl(commitIcon);\n                }\n                issue.addLink(linkBuilder.build());\n            }\n\n            // If prOnly is false, this is instead done when processing commits\n            if (prOnly && resolve) {\n                log.info(\"Resolving issue \" + issue.id() + \" from state \" + issue.state());\n                if (!issue.isFixed()) {\n                    issue.setState(Issue.State.RESOLVED);\n                } else {\n                    log.info(\"The issue was already resolved\");\n                }\n                if (issue.assignees().isEmpty()) {\n                    var username = findIssueUsername(commit, scratchPath);\n                    username.ifPresent(s -> setAssigneeForIssue(issue, s));\n                }\n            }\n        }\n    }\n\n    private void setAssigneeForIssue(IssueTrackerIssue issue, String username) {\n        var assignee = issueProject.issueTracker().user(username);\n        if (assignee.isPresent()) {\n            if (assignee.get().active()) {\n                log.info(\"Setting assignee for issue \" + issue.id() + \" to \" + assignee.get());\n                issue.setAssignees(List.of(assignee.get()));\n            } else {\n                log.warning(\"Skipping setting assignee for issue \" + issue.id() + \" to \" + assignee.get() + \" because the user is inactive\");\n            }\n        }\n    }\n\n    public void onTargetBranchChange(PullRequest pr, Path scratchPath, org.openjdk.skara.vcs.openjdk.Issue issue) {\n        var realIssue = issueProject.issue(issue.shortId());\n        if (realIssue.isEmpty()) {\n            log.warning(\"Pull request \" + pr + \" added unknown issue: \" + issue.id());\n            return;\n        }\n\n        if (reviewLink) {\n            // Remove the previous link\n            removeReviewLink(pr, realIssue.get());\n            // Add a new link\n            addReviewLink(pr, realIssue.get());\n        }\n\n        log.info(\"Updating review link comment to issue \" + realIssue.get().id());\n        PullRequestUtils.postPullRequestLinkComment(realIssue.get(), pr);\n    }\n\n    private void addReviewLink(PullRequest pr, IssueTrackerIssue realIssue) {\n        var linkBuilder = Link.create(pr.webUrl(), \"Review(\" + pr.targetRef() + \")\")\n                .summary(pr.repository().name() + \"/\" + pr.id());\n        if (reviewIcon != null) {\n            linkBuilder.iconTitle(\"Review\");\n            linkBuilder.iconUrl(reviewIcon);\n        }\n\n        log.info(\"Adding review link to issue \" + realIssue.id());\n        realIssue.addLink(linkBuilder.build());\n    }\n\n    private void removeReviewLink(PullRequest pr, IssueTrackerIssue realIssue) {\n        log.info(\"Removing review links from issue \" + realIssue.id());\n        var link = Link.create(pr.webUrl(), \"\").build();\n        realIssue.removeLink(link);\n    }\n\n    @Override\n    public void onNewIssue(PullRequest pr, Path scratchPath, org.openjdk.skara.vcs.openjdk.Issue issue) {\n        var realIssue = issueProject.issue(issue.shortId());\n        if (realIssue.isEmpty()) {\n            log.warning(\"Pull request \" + pr + \" added unknown issue: \" + issue.id());\n            return;\n        }\n\n        if (reviewLink) {\n            addReviewLink(pr, realIssue.get());\n        }\n\n        log.info(\"Adding review link comment to issue \" + realIssue.get().id());\n        PullRequestUtils.postPullRequestLinkComment(realIssue.get(), pr);\n    }\n\n    @Override\n    public void onRemovedIssue(PullRequest pr, Path scratchPath, org.openjdk.skara.vcs.openjdk.Issue issue) {\n        var realIssue = issueProject.issue(issue.shortId());\n        if (realIssue.isEmpty()) {\n            log.warning(\"Pull request \" + pr + \" removed unknown issue: \" + issue.id());\n            return;\n        }\n\n        removeReviewLink(pr, realIssue.get());\n\n        PullRequestUtils.removePullRequestLinkComment(realIssue.get(), pr);\n    }\n\n    @Override\n    public void onNewCommits(HostedRepository repository, Repository localRepository, Path scratchPath, List<Commit> commits, Branch branch) {\n        for (var commit : commits) {\n            var linkRepository = originalRepository != null ? originalRepository : repository;\n            var commitNotification = CommitFormatters.toTextBrief(linkRepository, commit, branch);\n            var commitMessage = CommitMessageParsers.v1.parse(commit);\n            var username = findIssueUsername(commit, scratchPath);\n\n            for (var commitIssue : commitMessage.issues()) {\n                var optionalIssue = issueProject.issue(commitIssue.shortId());\n                if (optionalIssue.isEmpty()) {\n                    log.severe(\"Cannot update issue \" + commitIssue.id() + \" with commit \" + commit.hash().abbreviate()\n                                       + \" - issue not found in issue project\");\n                    continue;\n                }\n\n                var issue = optionalIssue.get();\n                var mainIssue = Backports.findMainIssue(issue);\n                if (mainIssue.isEmpty()) {\n                    log.severe(\"Issue \" + issue.id() + \" is not the main issue - bot no corresponding main issue found\");\n                    continue;\n                } else {\n                    if (!mainIssue.get().id().equals(issue.id())) {\n                        log.warning(\"Issue \" + issue.id() + \" is not the main issue - using \" + mainIssue.get().id() + \" instead\");;\n                        issue = mainIssue.get();\n                    }\n                }\n\n                String requestedVersion = null;\n                // The actual issue to be updated can change depending on the fix version\n                if (setFixVersion) {\n                    requestedVersion = getRequestedVersion(localRepository, commit, branch.name());\n                    var altFixedVersionIssue = findAltFixedVersionIssue(issue, branch);\n                    if (altFixedVersionIssue.isPresent()) {\n                        log.info(\"Found an already fixed backport \" + altFixedVersionIssue.get().id() + \" for \" + issue.id()\n                                + \" with fixVersion \" + Backports.mainFixVersion(altFixedVersionIssue.get()).orElseThrow());\n                        issue = altFixedVersionIssue.get();\n                        // Do not update fixVersion\n                        requestedVersion = null;\n                    } else if (requestedVersion != null) {\n                        if (!multiFixVersions) {\n                            var fixVersion = JdkVersion.parse(requestedVersion).orElseThrow();\n                            var existing = Backports.findIssue(issue, fixVersion);\n                            if (existing.isEmpty()) {\n                                var issueFixVersion = Backports.mainFixVersion(issue);\n                                try {\n                                    if (issue.isOpen() && avoidForwardports && issueFixVersion.isPresent() && fixVersion.compareTo(issueFixVersion.get()) > 0) {\n                                        log.info(\"Avoiding 'forwardport', creating new backport for \" + issue.id() + \" with fixVersion \" + issueFixVersion.get().raw());\n                                        Backports.createBackport(issue, issueFixVersion.get().raw(), username.orElse(null), defaultSecurity(branch));\n                                    } else {\n                                        log.info(\"Creating new backport for \" + issue.id() + \" with fixVersion \" + requestedVersion);\n                                        issue = Backports.createBackport(issue, requestedVersion, username.orElse(null), defaultSecurity(branch));\n                                    }\n                                } catch (UncheckedRestException e) {\n                                    existing = Backports.findIssue(issue, fixVersion);\n                                    if (existing.isPresent()) {\n                                        log.info(\"Race condition occurred while creating backport issue, returning the existing backport for \" + issue.id() + \" and requested fixVersion \"\n                                                + requestedVersion + \" \" + existing.get().id());\n                                        issue = existing.get();\n                                    } else {\n                                        throw e;\n                                    }\n                                }\n                            } else {\n                                log.info(\"Found existing backport for \" + issue.id() + \" and requested fixVersion \"\n                                        + requestedVersion + \" \" + existing.get().id());\n                                issue = existing.get();\n                            }\n                        }\n                    }\n                }\n\n                var existingComments = issue.comments();\n                var hashUrl = linkRepository.webUrl(commit.hash()).toString();\n                // We used to store URLs with just the abbreviated hash, so need to check for both\n                var shortHashUrl = linkRepository.webUrl(new Hash(commit.hash().abbreviate())).toString();\n                var alreadyPostedComment = existingComments.stream()\n                        .filter(comment -> comment.author().equals(issueProject.issueTracker().currentUser()))\n                        .anyMatch(comment -> comment.body().contains(hashUrl) || comment.body().contains(shortHashUrl));\n                if (!alreadyPostedComment) {\n                    issue.addComment(commitNotification);\n                }\n                log.info(\"Resolving issue \" + issue.id() + \" from state \" + issue.state());\n                // If the issue here was found by findAltFixedVersionIssue(), issue.isFixed() should return true,\n                // so issue notifier won't do anything to the issue except posting a comment\n                if (!issue.isFixed()) {\n                    issue.setState(Issue.State.RESOLVED);\n                } else {\n                    log.info(\"The issue was already resolved\");\n                }\n                if (issue.assignees().isEmpty()) {\n                    if (username.isPresent()) {\n                        setAssigneeForIssue(issue, username.get());\n                    }\n                }\n\n                if (setFixVersion) {\n                    if (requestedVersion != null) {\n                        if (buildName != null) {\n                            // Check if the build name should be updated\n                            var oldBuild = issue.properties().getOrDefault(RESOLVED_IN_BUILD, JSON.of());\n                            if (BuildCompare.shouldReplace(buildName, oldBuild.asString())) {\n                                log.info(\"Setting resolved in build for \" + issue.id() + \" to \" + buildName);\n                                issue.setProperty(RESOLVED_IN_BUILD, JSON.of(buildName));\n                            } else {\n                                log.info(\"Not replacing build \" + oldBuild.asString() + \" with \" + buildName + \" for issue \" + issue.id());\n                            }\n                        }\n                        if (multiFixVersions) {\n                            var currentFixVersions = Backports.fixVersions(issue);\n                            log.info(\"Adding fixVersion \" + requestedVersion + \" to \" + issue.id() + \" current: \" + currentFixVersions);\n                            var jsonFixVersions = JSON.array();\n                            currentFixVersions.forEach(jsonFixVersions::add);\n                            jsonFixVersions.add(requestedVersion);\n                            issue.setProperty(\"fixVersions\", jsonFixVersions);\n                        } else {\n                            log.info(\"Setting fixVersion for \" + issue.id() + \" to \" + requestedVersion);\n                            issue.setProperty(\"fixVersions\", JSON.array().add(requestedVersion));\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    private Optional<IssueTrackerIssue> findAltFixedVersionIssue(IssueTrackerIssue issue, Branch branch) {\n        if (altFixVersions != null) {\n            var matchingBranchPattern = altFixVersions.keySet().stream()\n                    .filter(pattern -> pattern.matcher(branch.toString()).matches())\n                    .findFirst();\n            return matchingBranchPattern.flatMap(branchPattern -> altFixVersions.get(branchPattern).stream()\n                    .map(versionPattern -> Backports.findFixedIssue(issue, versionPattern))\n                    .flatMap(Optional::stream)\n                    .findFirst());\n        }\n        return Optional.empty();\n    }\n\n    private String defaultSecurity(Branch branch) {\n        return defaultSecurity.stream()\n                .filter(branchSecurity -> branchSecurity.branch.matcher(branch.name()).matches())\n                .map(BranchSecurity::securityId)\n                .findFirst()\n                .orElse(null);\n    }\n\n    @Override\n    public void onNewOpenJDKTagCommits(HostedRepository repository, Repository localRepository, Path scratchPath, List<Commit> commits, OpenJDKTag tag, Tag.Annotated annotated) throws NonRetriableException {\n        if (!setFixVersion) {\n            return;\n        }\n        if (buildName == null) {\n            return;\n        }\n        if (tag.buildNum().isEmpty()) {\n            return;\n        }\n\n        // Determine which branch(es) this tag belongs to\n        var tagBranches = new ArrayList<String>();\n        try {\n            for (var branch : repository.branches()) {\n                if (PreIntegrations.isPreintegrationBranch(branch.name())) {\n                    continue;\n                }\n                var hash = localRepository.resolve(tag.tag()).orElseThrow();\n                if (localRepository.isAncestor(hash, branch.hash())) {\n                    tagBranches.add(branch.name());\n                }\n            }\n            if (tagBranches.isEmpty()) {\n                throw new RuntimeException(\"Cannot find any branch containing the tag \" + tag.tag().name());\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n\n        for (var commit : commits) {\n            var commitMessage = CommitMessageParsers.v1.parse(commit);\n            for (var commitIssue : commitMessage.issues()) {\n                var optionalIssue = issueProject.issue(commitIssue.shortId());\n                if (optionalIssue.isEmpty()) {\n                    log.severe(\"Cannot update \\\"Resolved in Build\\\" for issue \" + commitIssue.id()\n                                       + \" - issue not found in issue project\");\n                    continue;\n                }\n                // The actual issue to be updated can change depending on the fix version\n                for (var tagBranch : tagBranches) {\n                    var issue = optionalIssue.get();\n                    String requestedVersion = getRequestedVersion(localRepository, commit, tagBranch);\n                    if (requestedVersion == null) {\n                        log.info(\"Cannot update \\\"Resolved In Build\\\" for issue: \" + issue.id() + \", branch: \"\n                                + tagBranch + \" - no fixVersion configured\");\n                        continue;\n                    }\n                    var fixVersion = JdkVersion.parse(requestedVersion).orElseThrow();\n                    var existing = Backports.findIssue(issue, fixVersion);\n                    if (existing.isEmpty()) {\n                        log.info(\"Cannot update \\\"Resolved in Build\\\" for issue: \" + issue.id() + \", branch: \"\n                                + tagBranch + \" - no suitable backport found\");\n                        continue;\n                    } else {\n                        issue = existing.get();\n                    }\n\n                    // Check if the build number should be updated\n                    var tagVersion = JdkVersion.parse(tag.version());\n                    if (tagVersion.isPresent() && tagVersionMatchesFixVersion(fixVersion, tagVersion.get())) {\n                        var oldBuild = issue.properties().getOrDefault(RESOLVED_IN_BUILD, JSON.of());\n                        var newBuild = \"b\" + String.format(\"%02d\", tag.buildNum().get());\n                        if (BuildCompare.shouldReplace(newBuild, oldBuild.asString())) {\n                            log.info(\"Setting resolved in build for \" + issue.id() + \" to \" + newBuild);\n                            issue.setProperty(RESOLVED_IN_BUILD, JSON.of(newBuild));\n                        } else {\n                            log.info(\"Not replacing build \" + oldBuild.asString() + \" with \" + newBuild + \" for issue \" + issue.id());\n                        }\n                    } else {\n                        log.info(\"Not updating build in issue \" + issue.id() + \" with fixVersion \" + fixVersion + \" from tag \" + tag);\n                    }\n                }\n            }\n        }\n    }\n\n    private boolean tagVersionMatchesFixVersion(JdkVersion fixVersion, JdkVersion tagVersion) {\n        // If the fix version has an opt string, check if it should be ignored, otherwise\n        // return false if it's not equal.\n        if (fixVersion.opt().isPresent() && !tagIgnoreOpt.contains(fixVersion.opt().get())\n                && !fixVersion.opt().equals(tagVersion.opt())) {\n            return false;\n        }\n        // At this point, if all the components are equal, we have a match\n        if (fixVersion.components().equals(tagVersion.components())) {\n            return true;\n        }\n        // The fixVersion may have a prefix consisting of only lower case letters in the\n        // first component that is not present in the tagVersion.\n        // e.g. 'openjdk8u342' vs '8u342'\n        if (!tagMatchPrefix) {\n            var fixComponents = fixVersion.components();\n            var tagComponents = tagVersion.components();\n            // Check that the rest of the components are equal\n            if (fixComponents.size() > 0 && fixComponents.size() == tagComponents.size()\n                    && fixComponents.subList(1, fixComponents.size()).equals(tagComponents.subList(1, tagComponents.size()))) {\n                var fixFirst = fixComponents.get(0);\n                var tagFirst = tagComponents.get(0);\n                // Check if the first fixVersion component without the prefix matches\n                return fixFirst.matches(\"[a-z]+\" + tagFirst);\n            }\n        }\n        return false;\n    }\n\n    private String getRequestedVersion(Repository localRepository, Commit commit, String branch) {\n        if (fixVersions != null) {\n            var matchingPattern = fixVersions.keySet().stream()\n                    .filter(pattern -> pattern.matcher(branch).matches())\n                    .findFirst();\n            if (matchingPattern.isPresent()) {\n                return fixVersions.get(matchingPattern.get());\n            }\n        }\n        try {\n            var hash = (useHeadVersion ? localRepository.resolve(branch).orElseThrow() : commit.hash());\n            var conf = localRepository.lines(Path.of(\".jcheck/conf\"), hash);\n            if (conf.isPresent()) {\n                var parsed = JCheckConfiguration.parse(conf.get());\n                var version = parsed.general().version();\n                return version.orElse(null);\n            }\n        } catch (IOException e) {\n            throw new RuntimeException(e);\n        }\n        return null;\n    }\n\n    @Override\n    public String name() {\n        return \"issue\";\n    }\n\n    @Override\n    public boolean idempotent() {\n        return true;\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/issue/IssueNotifierBuilder.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.issue;\n\nimport java.util.regex.Pattern;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.issuetracker.IssueProject;\n\nimport java.net.URI;\nimport java.util.*;\n\nclass IssueNotifierBuilder {\n    private IssueProject issueProject;\n    private boolean reviewLink = true;\n    private URI reviewIcon = null;\n    private boolean commitLink = true;\n    private URI commitIcon = null;\n    private boolean setFixVersion = false;\n    private LinkedHashMap<Pattern, String> fixVersions = null;\n    private LinkedHashMap<Pattern, List<Pattern>> altFixVersions = null;\n    private boolean prOnly = true;\n    private boolean repoOnly = false;\n    private String buildName = null;\n    private HostedRepository censusRepository = null;\n    private String censusRef = null;\n    private String namespace = \"openjdk.org\";\n    private boolean useHeadVersion = false;\n    private HostedRepository originalRepository;\n    private boolean resolve = true;\n    private Set<String> tagIgnoreOpt = Set.of();\n    private boolean tagMatchPrefix = false;\n    private List<IssueNotifier.BranchSecurity> defaultSecurity = List.of();\n    private boolean avoidForwardports = false;\n    private boolean multiFixVersions = false;\n\n    IssueNotifierBuilder issueProject(IssueProject issueProject) {\n        this.issueProject = issueProject;\n        return this;\n    }\n\n    IssueNotifierBuilder reviewLink(boolean reviewLink) {\n        this.reviewLink = reviewLink;\n        return this;\n    }\n\n    IssueNotifierBuilder reviewIcon(URI reviewIcon) {\n        this.reviewIcon = reviewIcon;\n        return this;\n    }\n\n    IssueNotifierBuilder commitLink(boolean commitLink) {\n        this.commitLink = commitLink;\n        return this;\n    }\n\n    IssueNotifierBuilder commitIcon(URI commitIcon) {\n        this.commitIcon = commitIcon;\n        return this;\n    }\n\n    public IssueNotifierBuilder setFixVersion(boolean setFixVersion) {\n        prOnly = false;\n        this.setFixVersion = setFixVersion;\n        return this;\n    }\n\n    public IssueNotifierBuilder fixVersions(LinkedHashMap<Pattern, String> fixVersions) {\n        this.fixVersions = fixVersions;\n        return this;\n    }\n\n    public IssueNotifierBuilder altFixVersions(LinkedHashMap<Pattern, List<Pattern>> altFixVersions) {\n        this.altFixVersions = altFixVersions;\n        return this;\n    }\n\n    public IssueNotifierBuilder prOnly(boolean prOnly) {\n        this.prOnly = prOnly;\n        return this;\n    }\n\n    public IssueNotifierBuilder repoOnly(boolean repoOnly) {\n        this.repoOnly = repoOnly;\n        return this;\n    }\n\n    public IssueNotifierBuilder buildName(String buildName) {\n        this.buildName = buildName;\n        return this;\n    }\n\n    public IssueNotifierBuilder censusRepository(HostedRepository censusRepository) {\n        this.censusRepository = censusRepository;\n        return this;\n    }\n\n    public IssueNotifierBuilder censusRef(String censusRef) {\n        this.censusRef = censusRef;\n        return this;\n    }\n\n    public IssueNotifierBuilder namespace(String namespace) {\n        this.namespace = namespace;\n        return this;\n    }\n\n    public IssueNotifierBuilder useHeadVersion(boolean useHeadVersion) {\n        this.useHeadVersion = useHeadVersion;\n        return this;\n    }\n\n    public IssueNotifierBuilder originalRepository(HostedRepository originalRepository) {\n        this.originalRepository = originalRepository;\n        return this;\n    }\n\n    public IssueNotifierBuilder resolve(boolean resolve) {\n        this.resolve = resolve;\n        return this;\n    }\n\n    public IssueNotifierBuilder tagIgnoreOpt(Set<String> tagIgnoreOpt) {\n        this.tagIgnoreOpt = tagIgnoreOpt;\n        return this;\n    }\n\n    public IssueNotifierBuilder tagMatchPrefix(boolean tagMatchPrefix) {\n        this.tagMatchPrefix = tagMatchPrefix;\n        return this;\n    }\n\n    public IssueNotifierBuilder defaultSecurity(List<IssueNotifier.BranchSecurity> defaultSecurity) {\n        this.defaultSecurity = defaultSecurity;\n        return this;\n    }\n\n    public IssueNotifierBuilder avoidForwardports(boolean avoidForwardports) {\n        this.avoidForwardports = avoidForwardports;\n        return this;\n    }\n\n    public IssueNotifierBuilder multiFixVersions(boolean multiFixVersions) {\n        this.multiFixVersions = multiFixVersions;\n        return this;\n    }\n\n    public boolean prOnly() {\n        return prOnly;\n    }\n\n    public boolean resolve() {\n        return resolve;\n    }\n\n    IssueNotifier build() {\n        return new IssueNotifier(issueProject, reviewLink, reviewIcon, commitLink, commitIcon,\n                setFixVersion, fixVersions, altFixVersions, prOnly,\n                repoOnly, buildName, censusRepository, censusRef, namespace, useHeadVersion, originalRepository,\n                resolve, tagIgnoreOpt, tagMatchPrefix, defaultSecurity, avoidForwardports, multiFixVersions);\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/issue/IssueNotifierFactory.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n package org.openjdk.skara.bots.notify.issue;\n\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.regex.Pattern;\nimport org.openjdk.skara.bot.BotConfiguration;\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.json.JSONObject;\nimport org.openjdk.skara.json.JSONValue;\nimport org.openjdk.skara.network.URIBuilder;\n\nimport java.net.URI;\nimport java.util.stream.Collectors;\n\npublic class IssueNotifierFactory implements NotifierFactory {\n    @Override\n    public String name() {\n        return \"issue\";\n    }\n\n    @Override\n    public Notifier create(BotConfiguration botConfiguration, JSONObject notifierConfiguration) {\n        var issueProject = botConfiguration.issueProject(notifierConfiguration.get(\"project\").asString());\n        var builder = IssueNotifier.newBuilder()\n                                   .issueProject(issueProject);\n\n        if (notifierConfiguration.contains(\"reviews\")) {\n            if (notifierConfiguration.get(\"reviews\").contains(\"icon\")) {\n                builder.reviewIcon(URI.create(notifierConfiguration.get(\"reviews\").get(\"icon\").asString()));\n            }\n        }\n        if (notifierConfiguration.contains(\"commits\")) {\n            if (notifierConfiguration.get(\"commits\").contains(\"icon\")) {\n                builder.commitIcon(URI.create(notifierConfiguration.get(\"commits\").get(\"icon\").asString()));\n            }\n        }\n\n        if (notifierConfiguration.contains(\"reviewlink\")) {\n            builder.reviewLink(notifierConfiguration.get(\"reviewlink\").asBoolean());\n        }\n        if (notifierConfiguration.contains(\"commitlink\")) {\n            builder.commitLink(notifierConfiguration.get(\"commitlink\").asBoolean());\n        }\n\n        if (notifierConfiguration.contains(\"fixversions\")) {\n            builder.setFixVersion(true);\n            var fixVersions = new LinkedHashMap<Pattern, String>();\n            notifierConfiguration.get(\"fixversions\").fields()\n                    .forEach(f -> fixVersions.put(Pattern.compile(f.name()), f.value().asString()));\n            builder.fixVersions(fixVersions);\n        }\n        if (notifierConfiguration.contains(\"altfixversions\")) {\n            var altFixVersions = new LinkedHashMap<Pattern, List<Pattern>>();\n            notifierConfiguration.get(\"altfixversions\").fields()\n                    .forEach(f -> altFixVersions.put(Pattern.compile(f.name()), f.value().asArray().stream()\n                            .map(JSONValue::asString)\n                            .map(Pattern::compile)\n                            .toList()));\n            builder.altFixVersions(altFixVersions);\n        }\n        if (notifierConfiguration.contains(\"buildname\")) {\n            builder.buildName(notifierConfiguration.get(\"buildname\").asString());\n        }\n\n        if (notifierConfiguration.contains(\"pronly\")) {\n            builder.prOnly(notifierConfiguration.get(\"pronly\").asBoolean());\n        }\n\n        if (notifierConfiguration.contains(\"resolve\")) {\n            builder.resolve(notifierConfiguration.get(\"resolve\").asBoolean());\n            if (!builder.resolve() && !builder.prOnly()) {\n                throw new RuntimeException(\"Cannot disable resolve when pronly is false\");\n            }\n        }\n\n        if (notifierConfiguration.contains(\"repoonly\")) {\n            builder.repoOnly(notifierConfiguration.get(\"repoonly\").asBoolean());\n        }\n\n        if (notifierConfiguration.contains(\"census\")) {\n            builder.censusRepository(botConfiguration.repository(notifierConfiguration.get(\"census\").asString()));\n            builder.censusRef(botConfiguration.repositoryRef(notifierConfiguration.get(\"census\").asString()));\n        }\n        if (notifierConfiguration.contains(\"namespace\")) {\n            builder.namespace(notifierConfiguration.get(\"namespace\").asString());\n        }\n\n        if (notifierConfiguration.contains(\"headversion\")) {\n            builder.useHeadVersion(notifierConfiguration.get(\"headversion\").asBoolean());\n        }\n\n        if (notifierConfiguration.contains(\"originalrepository\")) {\n            builder.originalRepository(botConfiguration.repository(notifierConfiguration.get(\"originalrepository\").asString()));\n        }\n\n        if (notifierConfiguration.contains(\"tag\")) {\n            var tag = notifierConfiguration.get(\"tag\");\n            if (tag.contains(\"ignoreopt\")) {\n                builder.tagIgnoreOpt(tag.get(\"ignoreopt\").stream()\n                        .map(JSONValue::asString)\n                        .collect(Collectors.toSet()));\n            }\n            if (tag.contains(\"matchprefix\")) {\n                builder.tagMatchPrefix(tag.get(\"matchprefix\").asBoolean());\n            }\n        }\n\n        if (notifierConfiguration.contains(\"defaultsecurity\")) {\n            var defaultSecurity = notifierConfiguration.get(\"defaultsecurity\").fields().stream()\n                    .map(e -> new IssueNotifier.BranchSecurity(Pattern.compile(e.name()), e.value().asString()))\n                    .toList();\n            builder.defaultSecurity(defaultSecurity);\n        }\n\n        if (notifierConfiguration.contains(\"avoidforwardports\")) {\n            builder.avoidForwardports(notifierConfiguration.get(\"avoidforwardports\").asBoolean());\n        }\n\n        if (notifierConfiguration.contains(\"multifixversions\")) {\n            builder.multiFixVersions(notifierConfiguration.get(\"multifixversions\").asBoolean());\n        }\n\n        return builder.build();\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/json/JsonNotifier.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.json;\n\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.*;\n\nimport java.nio.file.Path;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\n\nclass JsonNotifier implements Notifier, RepositoryListener {\n    private final Path path;\n    private final String version;\n    private final String defaultBuild;\n\n    JsonNotifier(Path path, String version, String defaultBuild) {\n        this.path = path;\n        this.version = version;\n        this.defaultBuild = defaultBuild;\n    }\n\n    private JSONObject commitToChanges(HostedRepository repository, Repository localRepository, Commit commit, String build) {\n        var ret = JSON.object();\n        ret.put(\"url\",  repository.webUrl(commit.hash()).toString()); //FIXME\n        ret.put(\"version\", version);\n        ret.put(\"build\", build);\n\n        var parsedMessage = CommitMessageParsers.v1.parse(commit);\n        var issueIds = JSON.array();\n        for (var issue : parsedMessage.issues()) {\n            issueIds.add(JSON.of(issue.shortId()));\n        }\n        ret.put(\"issue\", issueIds);\n        ret.put(\"user\", commit.author().name());\n        ret.put(\"date\", commit.authored().format(DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm:ss +0000\")));\n\n        return ret;\n    }\n\n    private JSONObject issuesToChanges(HostedRepository repository, Repository localRepository, List<Issue> issues, String build) {\n        var ret = JSON.object();\n        ret.put(\"version\", version);\n        ret.put(\"build\", build);\n\n        var issueIds = JSON.array();\n        for (var issue : issues) {\n            issueIds.add(JSON.of(issue.shortId()));\n        }\n\n        ret.put(\"issue\", issueIds);\n\n        return ret;\n    }\n\n    @Override\n    public void attachTo(Emitter e) {\n        e.registerRepositoryListener(this);\n    }\n\n    @Override\n    public void onNewCommits(HostedRepository repository, Repository localRepository, Path scratchPath, List<Commit> commits, Branch branch) throws NonRetriableException {\n        try (var writer = new JsonWriter(path, repository.name())) {\n            for (var commit : commits) {\n                var json = commitToChanges(repository, localRepository, commit, defaultBuild);\n                writer.write(json);\n            }\n        } catch (RuntimeException e) {\n            throw new NonRetriableException(e);\n        }\n    }\n\n    @Override\n    public void onNewOpenJDKTagCommits(HostedRepository repository, Repository localRepository, Path scratchPath, List<Commit> commits, OpenJDKTag tag, Tag.Annotated annotation) throws NonRetriableException {\n        if (tag.buildNum().isEmpty()) {\n            return;\n        }\n        var build = String.format(\"b%02d\", tag.buildNum().get());\n        try (var writer = new JsonWriter(path, repository.name())) {\n            var issues = new ArrayList<Issue>();\n            for (var commit : commits) {\n                var parsedMessage = CommitMessageParsers.v1.parse(commit);\n                issues.addAll(parsedMessage.issues());\n            }\n            var json = issuesToChanges(repository, localRepository, issues, build);\n            writer.write(json);\n        } catch (RuntimeException e) {\n            throw new NonRetriableException(e);\n        }\n    }\n\n    @Override\n    public String name() {\n        return \"json\";\n    }\n\n    @Override\n    public boolean idempotent() {\n        return false;\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/json/JsonNotifierFactory.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.json;\n\nimport org.openjdk.skara.bot.BotConfiguration;\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.json.JSONObject;\n\nimport java.nio.file.Path;\n\npublic class JsonNotifierFactory implements NotifierFactory {\n    @Override\n    public String name() {\n        return \"json\";\n    }\n\n    @Override\n    public Notifier create(BotConfiguration botConfiguration, JSONObject notifierConfiguration) {\n        var folder = notifierConfiguration.get(\"folder\").asString();\n        var build = notifierConfiguration.get(\"build\").asString();\n        var version = notifierConfiguration.get(\"version\").asString();\n\n        return new JsonNotifier(Path.of(folder), version, build);\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/json/JsonWriter.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.json;\n\nimport org.openjdk.skara.json.*;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.UUID;\n\nclass JsonWriter implements AutoCloseable {\n\n    private int sequence = 0;\n    private final String baseName;\n    private final Path path;\n    private JSONArray current;\n\n    private void flush() {\n        var tempName = path.resolve(String.format(\"%s.%03d.temp\", baseName, sequence));\n        var finalName = path.resolve(String.format(\"%s.%03d.json\", baseName, sequence));\n\n        try {\n            Files.writeString(tempName, current.toString());\n            Files.move(tempName, finalName);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n\n        sequence++;\n        current = JSON.array();\n    }\n\n    JsonWriter(Path path, String projectName) {\n        this.path = path;\n\n        var uuid = UUID.randomUUID();\n        baseName = \"jbs.\" + projectName.replace(\"/\", \".\") + \".\" + uuid.toString().replace(\"-\", \"\");\n        current = JSON.array();\n    }\n\n    public void write(JSONObject obj) {\n        current.add(obj);\n        if (current.size() > 100) {\n            flush();\n        }\n    }\n\n    @Override\n    public void close() {\n        if (current.size() > 0) {\n            flush();\n        }\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/mailinglist/MailingListNotifier.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.mailinglist;\n\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.email.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.mailinglist.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.OpenJDKTag;\n\nimport java.io.*;\nimport java.nio.file.Path;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\nclass MailingListNotifier implements Notifier, RepositoryListener {\n    private final MailingListServer server;\n    private final EmailAddress recipient;\n    private final EmailAddress sender;\n    private final EmailAddress author;\n    private final boolean includeBranch;\n    private final boolean reportNewTags;\n    private final boolean reportNewBranches;\n    private final boolean reportNewBuilds;\n    private final Mode mode;\n    private final Map<String, String> headers;\n    private final Pattern allowedAuthorDomains;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.notify\");\n\n    public enum Mode {\n        ALL,\n        PR\n    }\n\n    MailingListNotifier(MailingListServer server, EmailAddress recipient, EmailAddress sender, EmailAddress author,\n                        boolean includeBranch, boolean reportNewTags, boolean reportNewBranches, boolean reportNewBuilds,\n                        Mode mode, Map<String, String> headers, Pattern allowedAuthorDomains) {\n        this.server = server;\n        this.recipient = recipient;\n        this.sender = sender;\n        this.author = author;\n        this.includeBranch = includeBranch;\n        this.reportNewTags = reportNewTags;\n        this.reportNewBranches = reportNewBranches;\n        this.reportNewBuilds = reportNewBuilds;\n        this.mode = mode;\n        this.headers = headers;\n        this.allowedAuthorDomains = allowedAuthorDomains;\n    }\n\n    public static MailingListNotifierBuilder newBuilder() {\n        return new MailingListNotifierBuilder();\n    }\n\n    private String tagAnnotationToText(HostedRepository repository, Tag.Annotated annotation) {\n        var writer = new StringWriter();\n        var printer = new PrintWriter(writer);\n\n        printer.println(\"Tagged by: \" + annotation.author().name() + \" <\" + annotation.author().email() + \">\");\n        printer.println(\"Date:      \" + annotation.date().format(DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm:ss +0000\")));\n        printer.println();\n        printer.print(String.join(\"\\n\", annotation.message()));\n\n        return writer.toString();\n    }\n\n    private EmailAddress filteredAuthor(EmailAddress commitAddress) {\n        if (author != null) {\n            return author;\n        }\n        var allowedAuthorMatcher = allowedAuthorDomains.matcher(commitAddress.domain());\n        if (!allowedAuthorMatcher.matches()) {\n            return sender;\n        } else {\n            return commitAddress;\n        }\n    }\n\n    private EmailAddress commitToAuthor(Commit commit) {\n        return filteredAuthor(EmailAddress.from(commit.committer().name(), commit.committer().email()));\n    }\n\n    private EmailAddress annotationToAuthor(Tag.Annotated annotation) {\n        return filteredAuthor(EmailAddress.from(annotation.author().name(), annotation.author().email()));\n    }\n\n    private String commitsToSubject(HostedRepository repository, List<Commit> commits, Branch branch) {\n        var subject = new StringBuilder();\n        subject.append(repository.repositoryType().shortName());\n        subject.append(\": \");\n        subject.append(repository.name());\n        subject.append(\": \");\n        if (includeBranch) {\n            subject.append(branch.name());\n            subject.append(\": \");\n        }\n        if (commits.size() > 1) {\n            subject.append(commits.size());\n            subject.append(\" new changesets\");\n        } else {\n            subject.append(commits.get(0).message().get(0));\n        }\n        return subject.toString();\n    }\n\n    private String tagToSubject(HostedRepository repository, Hash hash, Tag tag) {\n        return repository.repositoryType().shortName() +\n                \": \" +\n                repository.name() +\n                \": Added tag \" +\n                tag +\n                \" for changeset \" +\n                hash.abbreviate();\n    }\n\n    private List<Commit> filterPrCommits(HostedRepository repository, Repository localRepository, List<Commit> commits, Branch branch) throws NonRetriableException {\n        var ret = new ArrayList<Commit>();\n        var mergedCommits = new HashSet<Hash>();\n\n        for (var commit : commits) {\n            var candidates = repository.findPullRequestsWithComment(null, \"Pushed as commit \" + commit.hash() + \".\");\n            if (candidates.size() != 1) {\n                if (candidates.size() > 1) {\n                    log.warning(\"Commit \" + commit.hash() + \" matches \" + candidates.size() + \" pull requests - expected 1\");\n                }\n                ret.add(commit);\n                continue;\n            }\n\n            var candidate = candidates.get(0);\n            var prLink = candidate.webUrl();\n            if (!candidate.targetRef().equals(branch.name())) {\n                log.info(\"Pull request \" + prLink + \" targets \" + candidate.targetRef() + \" - commit is on \" + branch.toString() + \" - skipping\");\n                ret.add(commit);\n                continue;\n            }\n\n            // For a merge PR, many other of these commits could belong here as well\n            if (commit.parents().size() > 1) {\n                if (!PullRequestUtils.isMerge(candidate)) {\n                    log.warning(\"Merge commit from non-merge PR?\");\n                    ret.add(commit);\n                    continue;\n                }\n\n                // For a merge PR, the first parent is always the target branch, so skip that one\n                for (int i = 1; i < commit.parents().size(); ++i) {\n                    try {\n                        localRepository.commitMetadata(commit.parents().get(0), commit.parents().get(i))\n                                       .forEach(c -> mergedCommits.add(c.hash()));\n                    } catch (IOException e) {\n                        log.warning(\"Unable to check if commits between \" + commit.parents().get(0) + \" and \"\n                                            + commit.parents().get(i) + \" were brought in through merging in \" + prLink);\n                    }\n                }\n            }\n        }\n\n        return ret.stream()\n                  .filter(c -> !mergedCommits.contains(c.hash()))\n                  .collect(Collectors.toList());\n    }\n\n    private void sendCombinedCommits(HostedRepository repository, List<Commit> commits, Branch branch) throws NonRetriableException {\n        if (commits.size() == 0) {\n            return;\n        }\n\n        var writer = new StringWriter();\n        var printer = new PrintWriter(writer);\n\n        for (var commit : commits) {\n            printer.println(CommitFormatters.toText(repository, commit, branch));\n        }\n\n        var subject = commitsToSubject(repository, commits, branch);\n        var lastCommit = commits.getLast();\n        var commitAddress = filteredAuthor(EmailAddress.from(lastCommit.committer().name(), lastCommit.committer().email()));\n        var email = Email.create(subject, writer.toString())\n                         .sender(sender)\n                         .author(commitAddress)\n                         .recipient(recipient)\n                         .headers(headers)\n                         .headers(commitHeaders(repository, commits))\n                         .build();\n\n        try {\n            log.info(\"Sending email for commits \" + String.join(\" \",\n                    commits.stream().map(Commit::hash).map(Hash::toString).toList())\n                    + \" on branch \" + branch + \" to \" + recipient);\n            server.post(email);\n        } catch (RuntimeException e) {\n            throw new NonRetriableException(e);\n        }\n    }\n\n    private Map<String, String> commitHeaders(HostedRepository repository, List<Commit> commits) {\n        var ret = new HashMap<String, String>();\n        ret.put(\"X-Git-URL\", repository.webUrl().toString());\n        if (!commits.isEmpty()) {\n            ret.put(\"X-Git-Changeset\", commits.get(0).hash().hex());\n        }\n        return ret;\n    }\n\n    @Override\n    public void attachTo(Emitter e) {\n        e.registerRepositoryListener(this);\n    }\n\n    @Override\n    public void onNewCommits(HostedRepository repository, Repository localRepository, Path scratchPath, List<Commit> commits, Branch branch) throws NonRetriableException {\n        if (mode == Mode.PR) {\n            commits = filterPrCommits(repository, localRepository, commits, branch);\n        }\n        sendCombinedCommits(repository, commits, branch);\n    }\n\n    @Override\n    public void onNewOpenJDKTagCommits(HostedRepository repository, Repository localRepository, Path scratchPath, List<Commit> commits, OpenJDKTag tag, Tag.Annotated annotation) throws NonRetriableException {\n        if (!reportNewTags) {\n            return;\n        }\n        if (!reportNewBuilds) {\n            onNewTagCommit(repository, localRepository, scratchPath, commits.getLast(), tag.tag(), annotation);\n            return;\n        }\n        var writer = new StringWriter();\n        var printer = new PrintWriter(writer);\n\n        var taggedCommit = commits.getLast();\n        if (annotation != null) {\n            printer.println(tagAnnotationToText(repository, annotation));\n        }\n        printer.println(CommitFormatters.toTextBrief(repository, taggedCommit, null));\n\n        printer.println(\"The following commits are included in \" + tag.tag());\n        printer.println(\"========================================================\");\n        for (var commit : commits) {\n            printer.print(commit.hash().abbreviate());\n            if (commit.message().size() > 0) {\n                printer.print(\": \" + commit.message().get(0));\n            }\n            printer.println();\n        }\n\n        var subject = tagToSubject(repository, taggedCommit.hash(), tag.tag());\n        var email = Email.create(subject, writer.toString())\n                         .sender(sender)\n                         .recipient(recipient)\n                         .headers(headers)\n                         .headers(commitHeaders(repository, commits));\n\n        if (annotation != null) {\n            email.author(annotationToAuthor(annotation));\n        } else {\n            email.author(commitToAuthor(taggedCommit));\n        }\n\n        log.info(\"Sending email for commits \" + String.join(\" \",\n                commits.stream().map(Commit::hash).map(Hash::toString).toList())\n                + \" for tag \" + tag + \" to \" + recipient);\n        try {\n            server.post(email.build());\n        } catch (RuntimeException e) {\n            throw new NonRetriableException(e);\n        }\n    }\n\n    @Override\n    public void onNewTagCommit(HostedRepository repository, Repository localRepository, Path scratchPath, Commit commit, Tag tag, Tag.Annotated annotation) throws NonRetriableException {\n        if (!reportNewTags) {\n            return;\n        }\n        var writer = new StringWriter();\n        var printer = new PrintWriter(writer);\n\n        if (annotation != null) {\n            printer.println(tagAnnotationToText(repository, annotation));\n        }\n        printer.println(CommitFormatters.toTextBrief(repository, commit, null));\n\n        var subject = tagToSubject(repository, commit.hash(), tag);\n        var email = Email.create(subject, writer.toString())\n                         .sender(sender)\n                         .recipient(recipient)\n                         .headers(headers)\n                         .headers(commitHeaders(repository, List.of(commit)));\n\n        if (annotation != null) {\n            email.author(annotationToAuthor(annotation));\n        } else {\n            email.author(commitToAuthor(commit));\n        }\n\n        log.info(\"Sending email for commit \" + commit\n                + \" for tag \" + tag + \" to \" + recipient);\n        try {\n            server.post(email.build());\n        } catch (RuntimeException e) {\n            throw new NonRetriableException(e);\n        }\n    }\n\n    private String newBranchSubject(HostedRepository repository, Repository localRepository, List<Commit> commits, Branch parent, Branch branch) {\n        var subject = new StringBuilder();\n        subject.append(repository.repositoryType().shortName());\n        subject.append(\": \");\n        subject.append(repository.name());\n        subject.append(\": created branch \");\n        subject.append(branch);\n        subject.append(\" based on the branch \");\n        subject.append(parent);\n        subject.append(\" containing \");\n        subject.append(commits.size());\n        subject.append(\" unique commit\");\n        if (commits.size() != 1) {\n            subject.append(\"s\");\n        }\n\n        return subject.toString();\n    }\n\n    @Override\n    public void onNewBranch(HostedRepository repository, Repository localRepository, Path scratchPath, List<Commit> commits, Branch parent, Branch branch) throws NonRetriableException {\n        if (!reportNewBranches) {\n            return;\n        }\n        var writer = new StringWriter();\n        var printer = new PrintWriter(writer);\n\n        if (commits.size() > 0) {\n            printer.println(\"The following commits are unique to the \" + branch.name() + \" branch:\");\n            printer.println(\"========================================================\");\n            for (var commit : commits) {\n                printer.print(commit.hash().abbreviate());\n                if (commit.message().size() > 0) {\n                    printer.print(\": \" + commit.message().get(0));\n                }\n                printer.println();\n            }\n        } else {\n            printer.println(\"The new branch \" + branch.name() + \" is currently identical to the \" + parent.name() + \" branch.\");\n        }\n\n        var subject = newBranchSubject(repository, localRepository, commits, parent, branch);\n        var finalAuthor = commits.size() > 0 ? commitToAuthor(commits.getLast()) : sender;\n\n        var email = Email.create(subject, writer.toString())\n                         .sender(sender)\n                         .author(finalAuthor)\n                         .recipient(recipient)\n                         .headers(headers)\n                         .headers(commitHeaders(repository, commits))\n                         .build();\n        log.info(\"Sending email for new branch \" + branch + \" to \" + recipient);\n        try {\n            server.post(email);\n        } catch (RuntimeException e) {\n            throw new NonRetriableException(e);\n        }\n    }\n\n    @Override\n    public String name() {\n        return \"ml\";\n    }\n\n    @Override\n    public boolean idempotent() {\n        return false;\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/mailinglist/MailingListNotifierBuilder.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.mailinglist;\n\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.mailinglist.*;\n\nimport java.util.Map;\nimport java.util.regex.Pattern;\n\nclass MailingListNotifierBuilder {\n    private MailingListServer server;\n    private EmailAddress recipient;\n    private EmailAddress sender;\n    private EmailAddress author = null;\n    private boolean includeBranch = false;\n    private boolean reportNewTags = true;\n    private boolean reportNewBranches = true;\n    private boolean reportNewBuilds = true;\n    private MailingListNotifier.Mode mode = MailingListNotifier.Mode.ALL;\n    private Map<String, String> headers = Map.of();\n    private Pattern allowedAuthorDomains = Pattern.compile(\".*\");\n    private boolean repoInSubject = false;\n    private Pattern branchInSubject = Pattern.compile(\"a^\"); // Does not match anything\n\n    public MailingListNotifierBuilder server(MailingListServer server) {\n        this.server = server;\n        return this;\n    }\n\n    public MailingListNotifierBuilder recipient(EmailAddress recipient) {\n        this.recipient = recipient;\n        return this;\n    }\n\n    public MailingListNotifierBuilder sender(EmailAddress sender) {\n        this.sender = sender;\n        return this;\n    }\n\n    public MailingListNotifierBuilder author(EmailAddress author) {\n        this.author = author;\n        return this;\n    }\n\n    public MailingListNotifierBuilder includeBranch(boolean includeBranch) {\n        this.includeBranch = includeBranch;\n        return this;\n    }\n\n    public MailingListNotifierBuilder reportNewTags(boolean reportNewTags) {\n        this.reportNewTags = reportNewTags;\n        return this;\n    }\n\n    public MailingListNotifierBuilder reportNewBranches(boolean reportNewBranches) {\n        this.reportNewBranches = reportNewBranches;\n        return this;\n    }\n\n    public MailingListNotifierBuilder reportNewBuilds(boolean reportNewBuilds) {\n        this.reportNewBuilds = reportNewBuilds;\n        return this;\n    }\n\n    public MailingListNotifierBuilder mode(MailingListNotifier.Mode mode) {\n        this.mode = mode;\n        return this;\n    }\n\n    public MailingListNotifierBuilder headers(Map<String, String> headers) {\n        this.headers = headers;\n        return this;\n    }\n\n    public MailingListNotifierBuilder allowedAuthorDomains(Pattern allowedAuthorDomains) {\n        this.allowedAuthorDomains = allowedAuthorDomains;\n        return this;\n    }\n\n    public MailingListNotifier build() {\n        return new MailingListNotifier(server, recipient, sender, author, includeBranch, reportNewTags, reportNewBranches,\n                                       reportNewBuilds, mode, headers, allowedAuthorDomains);\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/mailinglist/MailingListNotifierFactory.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.mailinglist;\n\nimport org.openjdk.skara.bot.BotConfiguration;\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.json.JSONObject;\nimport org.openjdk.skara.mailinglist.MailingListServerFactory;\n\nimport java.time.Duration;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class MailingListNotifierFactory implements NotifierFactory {\n    @Override\n    public String name() {\n        return \"mailinglist\";\n    }\n\n    @Override\n    public Notifier create(BotConfiguration botConfiguration, JSONObject notifierConfiguration) {\n        var smtp = notifierConfiguration.get(\"smtp\").asString();\n        var sender = EmailAddress.parse(notifierConfiguration.get(\"sender\").asString());\n        var interval = notifierConfiguration.contains(\"interval\") ? Duration.parse(notifierConfiguration.get(\"interval\").asString()) : Duration.ofSeconds(1);\n        var listServer = MailingListServerFactory.createSendOnlyServer(smtp, interval);\n\n        var recipient = notifierConfiguration.get(\"recipient\").asString();\n        var recipientAddress = EmailAddress.parse(recipient);\n\n        var author = notifierConfiguration.contains(\"author\") ? EmailAddress.parse(notifierConfiguration.get(\"author\").asString()) : null;\n        var allowedDomains = author == null ? Pattern.compile(notifierConfiguration.get(\"domains\").asString()) : null;\n\n        var builder = MailingListNotifier.newBuilder()\n                                         .server(listServer)\n                                         .recipient(recipientAddress)\n                                         .sender(sender)\n                                         .author(author)\n                                         .allowedAuthorDomains(allowedDomains);\n\n        if (notifierConfiguration.contains(\"mode\")) {\n            MailingListNotifier.Mode mode;\n            switch (notifierConfiguration.get(\"mode\").asString()) {\n                case \"all\":\n                    mode = MailingListNotifier.Mode.ALL;\n                    break;\n                case \"pr\":\n                    mode = MailingListNotifier.Mode.PR;\n                    break;\n                default:\n                    throw new RuntimeException(\"Unknown mode\");\n            }\n            builder.mode(mode);\n        }\n        if (notifierConfiguration.contains(\"headers\")) {\n            builder.headers(notifierConfiguration.get(\"headers\")\n                                                 .fields()\n                                                 .stream()\n                                                 .collect(Collectors.toMap(JSONObject.Field::name,\n                                                                           field -> field.value().asString())));\n        }\n        if (notifierConfiguration.contains(\"branchnames\")) {\n            builder.includeBranch(notifierConfiguration.get(\"branchnames\").asBoolean());\n        }\n        if (notifierConfiguration.contains(\"tags\")) {\n            builder.reportNewTags(notifierConfiguration.get(\"tags\").asBoolean());\n        }\n        if (notifierConfiguration.contains(\"branches\")) {\n            builder.reportNewBranches(notifierConfiguration.get(\"branches\").asBoolean());\n        }\n        if (notifierConfiguration.contains(\"builds\")) {\n            builder.reportNewBuilds(notifierConfiguration.get(\"builds\").asBoolean());\n        }\n\n        return builder.build();\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/notes/CommitNoteNotifier.java",
    "content": "/*\n * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.notes;\n\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\nclass CommitNoteNotifier implements Notifier, PullRequestListener {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.notify\");\n\n    private final IssueProject issueProject;\n\n    CommitNoteNotifier(IssueProject issueProject) {\n        this.issueProject = issueProject;\n    }\n\n    private List<IssueTrackerIssue> issues(Commit commit) {\n        var commitMessage = CommitMessageParsers.v1.parse(commit.metadata());\n        return commitMessage.issues()\n                            .stream()\n                            .map(i -> issueProject.issue(i.shortId()))\n                            .filter(Optional::isPresent)\n                            .map(Optional::get)\n                            .collect(Collectors.toList());\n    }\n\n    @Override\n    public void attachTo(Emitter e) {\n        e.registerPullRequestListener(this);\n    }\n\n    @Override\n    public void onIntegratedPullRequest(PullRequest pr, Path scratchPath, Hash hash)  {\n        try {\n            var pool = new HostedRepositoryPool(scratchPath.resolve(\"pool\"));\n            var localRepoDir = scratchPath.resolve(pr.repository().name());\n            var localRepo = pool.materialize(pr.repository(), localRepoDir);\n            localRepo.fetch(pr.repository().authenticatedUrl(), hash.hex(), true);\n\n            var commit = pr.repository().commit(hash).orElseThrow(() ->\n                    new IllegalStateException(\"Integrated commit \" + hash +\n                                            \" not present in repository \" + pr.repository().webUrl())\n            );\n            var issues = issues(commit);\n\n            var note = new ArrayList<String>();\n            note.add(\"Commit: \" + commit.webUrl());\n            note.add(\"Review: \" + pr.webUrl());\n            if (!issues.isEmpty()) {\n                note.add(\"Issues:\");\n                for (var issue : issues) {\n                    note.add(\"- \" + issue.webUrl());\n                }\n            }\n\n            localRepo.fetch(pr.repository().authenticatedUrl(), \"refs/notes/*:refs/notes/*\");\n            var existingNotes = localRepo.notes(hash);\n            if (existingNotes.isEmpty()) {\n                localRepo.addNote(hash, note, \"Duke\", \"duke@openjdk.org\");\n                localRepo.pushNotes(pr.repository().authenticatedUrl());\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public String name() {\n        return \"notes\";\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/notes/CommitNoteNotifierFactory.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.notes;\n\nimport org.openjdk.skara.bot.BotConfiguration;\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.json.JSONObject;\n\nimport java.net.URI;\n\npublic class CommitNoteNotifierFactory implements NotifierFactory {\n    @Override\n    public String name() {\n        return \"notes\";\n    }\n\n    @Override\n    public Notifier create(BotConfiguration botConfiguration, JSONObject notifierConfiguration) {\n        var issueProject = botConfiguration.issueProject(notifierConfiguration.get(\"project\").asString());\n        return new CommitNoteNotifier(issueProject);\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/prbranch/PullRequestBranchNotifier.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.prbranch;\n\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.nio.file.Path;\nimport java.util.logging.Logger;\n\npublic class PullRequestBranchNotifier implements Notifier, PullRequestListener {\n    private final Path seedFolder;\n    private final boolean protectBranches;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.notify\");\n\n    public PullRequestBranchNotifier(Path seedFolder, boolean protectBranches) {\n        this.seedFolder = seedFolder;\n        this.protectBranches = protectBranches;\n    }\n\n    @Override\n    public void attachTo(Emitter e) {\n        e.registerPullRequestListener(this);\n    }\n\n    private void pushBranch(PullRequest pr) {\n        var hostedRepositoryPool = new HostedRepositoryPool(seedFolder);\n        try {\n            var seedRepo = hostedRepositoryPool.seedRepository(pr.repository(), false);\n            seedRepo.fetch(pr.repository().authenticatedUrl(), pr.headHash().hex()).orElseThrow();\n            String branch = PreIntegrations.preIntegrateBranch(pr);\n            log.info(\"Creating new pull request pre-integration branch \" + branch);\n            seedRepo.push(pr.headHash(), pr.repository().authenticatedUrl(), branch, true);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    private void deleteBranch(PullRequest pr) {\n        String branch = PreIntegrations.preIntegrateBranch(pr);\n        var branchExists = pr.repository().branchHash(branch).isPresent();\n        if (protectBranches) {\n            // We still need this code because it's possible that we have some pr branch protected,\n            // but it will be fine for us to remove this code later\n            log.info(\"Removing branch protection for \" + branch);\n            pr.repository().unprotectBranchPattern(branch);\n            log.info(\"Removing branch protection for *\");\n            pr.repository().unprotectBranchPattern(\"*\");\n        }\n        if (!branchExists) {\n            log.info(\"Pull request pre-integration branch \" + branch + \" doesn't exist on remote - ignoring\");\n            return;\n        }\n        log.info(\"Deleting pull request pre-integration branch \" + branch);\n        pr.repository().deleteBranch(branch);\n        if (protectBranches) {\n            log.info(\"Protecting branch * after deleting branch \" + branch);\n            pr.repository().protectBranchPattern(\"*\");\n        }\n    }\n\n    @Override\n    public void onNewPullRequest(PullRequest pr, Path scratchPath) {\n        if (pr.state() == Issue.State.OPEN) {\n            pushBranch(pr);\n        }\n    }\n\n    @Override\n    public void onStateChange(PullRequest pr, Path scratchPath, Issue.State oldState) {\n        if (pr.state() == Issue.State.CLOSED) {\n            var retargetedDependencies = PreIntegrations.retargetDependencies(pr);\n            deleteBranch(pr);\n            if (pr.labelNames().contains(\"integrated\")) {\n                for (var retargeted : retargetedDependencies) {\n                    log.info(\"Posting retargeted comment on PR \" + pr.id());\n                    retargeted.addComment(\"\"\"\n                            The parent pull request that this pull request depends on has now been integrated and \\\n                            the target branch of this pull request has been updated. This means that changes from \\\n                            the dependent pull request can start to show up as belonging to this pull request, \\\n                            which may be confusing for reviewers. To remedy this situation, simply merge the latest \\\n                            changes from the new target branch into this pull request by running commands \\\n                            similar to these in the local repository for your personal fork:\n\n                            ```bash\n                            git checkout %s\n                            git fetch %s %s\n                            git merge FETCH_HEAD\n                            # if there are conflicts, follow the instructions given by git merge\n                            git commit -m \"Merge %s\"\n                            git push\n                            ```\n                            \"\"\".formatted(retargeted.sourceRef(), pr.repository().url(), pr.targetRef(),\n                            pr.targetRef()));\n                }\n            } else {\n                for (var retargeted : retargetedDependencies) {\n                    log.info(\"Posting retargeted comment on PR \" + pr.id());\n                    retargeted.addComment(\"\"\"\n                            The parent pull request that this pull request depends on has been closed without being \\\n                            integrated and the target branch of this pull request has been updated as the previous \\\n                            branch was deleted. This means that changes from the parent pull request will start to \\\n                            show up in this pull request. If closing the parent pull request was done in error, it will \\\n                            need to be re-opened and this pull request will need to manually be retargeted again.\n                            \"\"\");\n                }\n            }\n        } else {\n            pushBranch(pr);\n        }\n    }\n\n    @Override\n    public String name() {\n        return \"pullrequestbranch\";\n    }\n\n    @Override\n    public void onHeadChange(PullRequest pr, Path scratchPath, Hash oldHead) {\n        if (pr.state() == Issue.State.OPEN) {\n            pushBranch(pr);\n        }\n    }\n\n    @Override\n    public void initialize(HostedRepository repository) {\n        if (protectBranches) {\n            log.info(\"Protecting branch *\");\n            repository.protectBranchPattern(\"*\");\n        }\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/prbranch/PullRequestBranchNotifierFactory.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.prbranch;\n\nimport org.openjdk.skara.bot.BotConfiguration;\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.json.JSONObject;\n\npublic class PullRequestBranchNotifierFactory implements NotifierFactory {\n    @Override\n    public String name() {\n        return \"prbranch\";\n    }\n\n    @Override\n    public Notifier create(BotConfiguration botConfiguration, JSONObject notifierConfiguration) {\n        var seedFolder = botConfiguration.storageFolder();\n        var protectBranches = false;\n        if (notifierConfiguration.contains(\"protect\")) {\n            protectBranches = notifierConfiguration.get(\"protect\").asBoolean();\n        }\n        return new PullRequestBranchNotifier(seedFolder.resolve(\"seeds\"), protectBranches);\n    }\n\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/slack/SlackNotifier.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.slack;\n\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.network.*;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.net.URI;\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.time.format.DateTimeFormatter;\n\nclass SlackNotifier implements Notifier, RepositoryListener, PullRequestListener {\n    private final RestRequest prWebhook;\n    private final RestRequest commitWebhook;\n    private final String username;\n\n    SlackNotifier(URI prWebhook, URI commitWebhook, String username) {\n        this.prWebhook = prWebhook != null ? new RestRequest(prWebhook) : null;\n        this.commitWebhook = commitWebhook != null ? new RestRequest(commitWebhook) : null;\n        this.username = username;\n    }\n\n    @Override\n    public void attachTo(Emitter e) {\n        e.registerPullRequestListener(this);\n        e.registerRepositoryListener(this);\n    }\n\n    @Override\n    public void onNewPullRequest(PullRequest pr, Path scratchPath) {\n        if (prWebhook == null) {\n            return;\n        }\n\n        try {\n            var query = JSON.object();\n            query.put(\"text\", pr.nonTransformedWebUrl().toString());\n            if (username != null && !username.isEmpty()) {\n                query.put(\"username\", username);\n            }\n            prWebhook.post(\"\").body(query).executeUnparsed();\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public void onNewCommits(HostedRepository repository,\n                             Repository localRepository,\n                             Path scratchPath, List<Commit> commits,\n                             Branch branch) throws NonRetriableException {\n        if (commitWebhook == null) {\n            return;\n        }\n\n        try {\n            for (var commit : commits) {\n                var query = JSON.object();\n                if (username != null && !username.isEmpty()) {\n                    query.put(\"username\", username);\n                }\n                var title = commit.message().get(0);\n                query.put(\"text\", branch.name() + \": \" + commit.hash().abbreviate() + \": \" + title + \"\\n\" +\n                                  \"Author: \" + commit.author().name() + \"\\n\" +\n                                  \"Committer: \" + commit.author().name() + \"\\n\" +\n                                  \"Date: \" + commit.authored().format(DateTimeFormatter.RFC_1123_DATE_TIME) + \"\\n\");\n\n                var attachment = JSON.object();\n                attachment.put(\"fallback\", \"Link to commit\");\n                attachment.put(\"color\", \"#cc0e31\");\n                attachment.put(\"title\", \"View on \" + repository.forge().name());\n                attachment.put(\"title_link\", repository.webUrl(commit.hash()).toString());\n                var attachments = JSON.array();\n                attachments.add(attachment);\n                query.put(\"attachments\", attachments);\n                commitWebhook.post(\"\").body(query).executeUnparsed();\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public String name() {\n        return \"slack\";\n    }\n\n    @Override\n    public boolean idempotent() {\n        return false;\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/main/java/org/openjdk/skara/bots/notify/slack/SlackNotifierFactory.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.slack;\n\nimport org.openjdk.skara.bot.BotConfiguration;\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.json.JSONObject;\nimport org.openjdk.skara.network.URIBuilder;\n\nimport java.net.URI;\n\npublic class SlackNotifierFactory implements NotifierFactory {\n    @Override\n    public String name() {\n        return \"slack\";\n    }\n\n    @Override\n    public Notifier create(BotConfiguration botConfiguration, JSONObject notifierConfiguration) {\n        URI prWebhook = null;\n        if (notifierConfiguration.contains(\"pr\")) {\n            prWebhook = URIBuilder.base(notifierConfiguration.get(\"pr\").asString()).build();\n        }\n        URI commitWebhook = null;\n        if (notifierConfiguration.contains(\"commit\")) {\n            commitWebhook = URIBuilder.base(notifierConfiguration.get(\"commit\").asString()).build();\n        }\n        var username = notifierConfiguration.get(\"username\").asString();\n        return new SlackNotifier(prWebhook, commitWebhook, username);\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/test/java/org/openjdk/skara/bots/notify/NotifyBotFactoryTest.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.json.JWCC;\nimport org.openjdk.skara.test.*;\n\nimport java.util.Comparator;\nimport java.util.List;\nimport java.util.Objects;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass NotifyBotFactoryTest {\n    @Test\n    public void testCreate() {\n        try (var tempFolder = new TemporaryDirectory()) {\n            String jsonString = \"\"\"\n                    {\n                      \"database\": {\n                        \"repository\": \"notify:master\",\n                        \"name\": \"test_notify\",\n                        \"email\": \"test_notify@openjdk.org\"\n                      },\n                      \"ready\": {\n                        \"labels\": [\n                          \"rfr\"\n                        ],\n                        \"comments\": [\n                          {\n                            \"user\": \"test[bot]\",\n                            \"pattern\": \"<!-- Welcome message -->\"\n                          }\n                        ]\n                      },\n                      \"integrator\": \"111\",\n                      \"mailinglist\": {\n                        \"archive\": \"https://test.openjdk.org/archive\",\n                        \"smtp\": \"0.0.0.0\",\n                        \"sender\": \"test <test@openjdk.org>\",\n                        \"interval\": \"PT5S\"\n                      },\n                      \"issue\": {\n                        \"reviews\": {\n                          \"icon\": \"icon.png\"\n                        },\n                        \"commits\": {\n                          \"icon\": \"commit.png\"\n                        },\n                        \"namespace\": \"test.org\"\n                      },\n                      \"repositories\": {\n                        \"repo1\": {\n                          \"basename\": \"test-repo\",\n                          \"branches\": \"master\",\n                          \"mailinglist\": {\n                            \"recipient\": \"test@test.org\",\n                            \"domains\": \"test.org|test.com\",\n                            \"headers\": {\n                              \"Approved\": \"0000000\"\n                            },\n                            \"branchnames\": false,\n                            \"branches\": false,\n                            \"tags\": true,\n                            \"builds\": false\n                          },\n                          \"issue\": {\n                            \"project\": \"test_bugs/TEST\",\n                            \"pronly\": true,\n                            \"resolve\": false\n                          },\n                          \"comment\": {\n                            \"project\": \"test_bugs/TEST\"\n                          },\n                          \"prbranch\": {\n                          },\n                          \"notes\": {\n                            \"project\": \"test_bugs/TEST\"\n                          }\n                        },\n                        \"repo2\": {\n                          \"basename\": \"test-repo2\",\n                          \"branches\": \"dev\",\n                          \"mailinglist\": {\n                            \"recipient\": \"test@test.org\",\n                            \"domains\": \"test.org|test.com\",\n                            \"headers\": {\n                              \"Approved\": \"0000000\"\n                            },\n                            \"branchnames\": false,\n                            \"branches\": false,\n                            \"tags\": true,\n                            \"builds\": false\n                          },\n                          \"issue\": {\n                            \"project\": \"test_bugs/TEST\",\n                            \"pronly\": true,\n                            \"resolve\": false,\n                            \"multifixversions\": true,\n                          },\n                          \"comment\": {\n                            \"project\": \"test_bugs/TEST\"\n                          },\n                          \"prbranch\": {\n                          },\n                          \"notes\": {\n                            \"project\": \"test_bugs/TEST\"\n                          }\n                        }\n                      }\n                    }\n                    \"\"\";\n            var jsonConfig = JWCC.parse(jsonString).asObject();\n\n            var testHost = TestHost.createNew(List.of());\n            var testBotFactory = TestBotFactory.newBuilder()\n                    .addHostedRepository(\"notify\", new TestHostedRepository(\"notify\"))\n                    .addHostedRepository(\"repo1\", new TestHostedRepository(testHost, \"repo1\"))\n                    .addHostedRepository(\"repo2\", new TestHostedRepository(testHost, \"repo2\"))\n                    .addIssueProject(\"test_bugs/TEST\", new TestIssueProject(testHost, \"TEST\"))\n                    .storagePath(tempFolder.path().resolve(\"storage\"))\n                    .build();\n\n            var bots = testBotFactory.createBots(NotifyBotFactory.NAME, jsonConfig);\n            bots = bots.stream().sorted(Comparator.comparing(Objects::toString)).toList();\n            //A notifyBot for every configured repository\n            assertEquals(2, bots.size());\n\n            NotifyBot notifyBot1 = (NotifyBot) bots.get(0);\n            assertEquals(\"NotifyBot@repo1\", notifyBot1.toString());\n            assertEquals(\"master\", notifyBot1.getBranches().toString());\n            assertEquals(\"{test[bot]=<!-- Welcome message -->}\", notifyBot1.getReadyComments().toString());\n\n            NotifyBot notifyBot2 = (NotifyBot) bots.get(1);\n            assertEquals(\"NotifyBot@repo2\", notifyBot2.toString());\n            assertEquals(\"dev\", notifyBot2.getBranches().toString());\n            assertEquals(\"{test[bot]=<!-- Welcome message -->}\", notifyBot2.getReadyComments().toString());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/test/java/org/openjdk/skara/bots/notify/RepositoryWorkItemTests.java",
    "content": "/*\n * Copyright (c) 2021, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.regex.Pattern;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.openjdk.skara.bots.notify.TestUtils.createBranchStorage;\nimport static org.openjdk.skara.bots.notify.TestUtils.createTagStorage;\n\npublic class RepositoryWorkItemTests {\n\n    private static class TestNotifier implements RepositoryListener {\n\n        private final List<Tag> newTags = new ArrayList<>();\n\n        @Override\n        public void onNewTagCommit(HostedRepository repository, Repository localRepository,\n                                   Path scratchPath, Commit commit, Tag tag, Tag.Annotated annotation) {\n            newTags.add(tag);\n        }\n\n        @Override\n        public String name() {\n            return \"test\";\n        }\n\n        @Override\n        public boolean idempotent() {\n            return true;\n        }\n    }\n\n    /**\n     * Tests that the NotifierBot skips notifying on tags that only show up in\n     * pr branches.\n     */\n    @Test\n    void filterTagsInNonPrBranches(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var notifyBot = NotifyBot.newBuilder()\n                    .repository(repo)\n                    .storagePath(storageFolder)\n                    .branches(Pattern.compile(\"master\"))\n                    .tagStorageBuilder(tagStorage)\n                    .branchStorageBuilder(branchStorage)\n                    .integratorId(repo.forge().currentUser().id())\n                    .build();\n            var testNotifier = new TestNotifier();\n            notifyBot.registerRepositoryListener(testNotifier);\n\n            // Create an initial tag to start history tracking. The notifier will never notify the first tag\n            var masterHash = localRepo.head();\n            localRepo.tag(masterHash, \"initial-tag\", \"Tagging initial tag\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(masterHash, repo.authenticatedUrl(), \"master\", false, true);\n\n            // Run bot to initialize notification history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create a \"pr\"-branch with a commit in it and tag that commit\n            var prBranchHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"Change in pr branch\");\n            localRepo.tag(prBranchHash, \"pr-tag\", \"Tagging change in pr branch\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(prBranchHash, repo.authenticatedUrl(), \"pr/4711\", false, true);\n\n            // Run the bot and verify that notifier is not called\n            TestBotRunner.runPeriodicItems(notifyBot);\n            assertTrue(testNotifier.newTags.isEmpty(), \"Notifier called on pr branch: \" + testNotifier.newTags);\n\n            // Create a commit in master branch and tag it\n            localRepo.checkout(masterHash);\n            var masterTaggedHash = CheckableRepository.appendAndCommit(localRepo, \"Master line\", \"Change in master branch\");\n            localRepo.tag(masterTaggedHash, \"master-tag\", \"Tagging change in master branch\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(masterTaggedHash, repo.authenticatedUrl(), \"master\", false, true);\n\n            // Run the bot and verify that notifier is called for master branch\n            TestBotRunner.runPeriodicItems(notifyBot);\n            assertEquals(testNotifier.newTags.size(), 1, \"Notifier not called on master branch: \" + testNotifier.newTags);\n            assertEquals(\"master-tag\", testNotifier.newTags.get(0).name(), \"Notified wrong tag\");\n        }\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/test/java/org/openjdk/skara/bots/notify/TestUtils.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.storage.StorageBuilder;\n\npublic class TestUtils {\n    public static StorageBuilder<UpdatedTag> createTagStorage(HostedRepository repository) {\n        return new StorageBuilder<UpdatedTag>(\"tags.txt\")\n                .remoteRepository(repository, \"history\", \"Duke\", \"duke@openjdk.org\", \"Updated tags\");\n    }\n\n    public static StorageBuilder<UpdatedBranch> createBranchStorage(HostedRepository repository) {\n        return new StorageBuilder<UpdatedBranch>(\"branches.txt\")\n                .remoteRepository(repository, \"history\", \"Duke\", \"duke@openjdk.org\", \"Updated branches\");\n    }\n\n    public static StorageBuilder<PullRequestState> createPullRequestStateStorage(HostedRepository repository) {\n        return new StorageBuilder<PullRequestState>(\"prissues.txt\")\n                .remoteRepository(repository, \"history\", \"Duke\", \"duke@openjdk.org\", \"Updated prissues\");\n    }\n\n    // Test implementation of a RepositoryListener that does nothing\n    public static class NullRepositoryListener implements RepositoryListener {\n\n        @Override\n        public String name() {\n            return \"null\";\n        }\n\n        @Override\n        public boolean idempotent() {\n            return true;\n        }\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/test/java/org/openjdk/skara/bots/notify/UpdateHistoryTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.storage.StorageBuilder;\nimport org.openjdk.skara.test.HostCredentials;\nimport org.openjdk.skara.test.TestableRepository;\nimport org.openjdk.skara.vcs.Tag;\nimport org.openjdk.skara.vcs.*;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass UpdateHistoryTests {\n    private String resetHostedRepository(HostedRepository repository) throws IOException {\n        var folder = Files.createTempDirectory(\"updatehistory\");\n        var localRepository = TestableRepository.init(folder, repository.repositoryType());\n        var firstFile = folder.resolve(\"first.txt\");\n        Files.writeString(firstFile, \"First file to commit\");\n        localRepository.add(firstFile);\n        var firstCommit = localRepository.commit(\"First commit\", \"Duke\", \"duke@openjdk.org\");\n        localRepository.push(firstCommit, repository.authenticatedUrl(), localRepository.defaultBranch().toString(), true);\n        return localRepository.defaultBranch().toString();\n    }\n\n    private UpdateHistory createHistory(HostedRepository repository, String ref) throws IOException {\n        var folder = Files.createTempDirectory(\"updatehistory\");\n        var tagStorage = new StorageBuilder<UpdatedTag>(\"tags.txt\")\n                                       .remoteRepository(repository, ref, \"Duke\", \"duke@openjdk.org\", \"Updated tags\");\n        var branchStorage = new StorageBuilder<UpdatedBranch>(\"branches.txt\")\n                .remoteRepository(repository, ref, \"Duke\", \"duke@openjdk.org\", \"Updated branches\");\n        return UpdateHistory.create(tagStorage,folder.resolve(\"tags\"), branchStorage, folder.resolve(\"branches\"));\n    }\n\n    @Test\n    void tagsRetained(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repository = credentials.getHostedRepository();\n            var ref = resetHostedRepository(repository);\n            var history = createHistory(repository, ref);\n\n            history.addTags(List.of(new Tag(\"1\"), new Tag(\"2\")), \"a\");\n\n            assertTrue(history.hasTag(new Tag(\"1\"), \"a\"));\n            assertTrue(history.hasTag(new Tag(\"2\"), \"a\"));\n\n            var newHistory = createHistory(repository, ref);\n\n            assertTrue(newHistory.hasTag(new Tag(\"1\"), \"a\"));\n            assertTrue(newHistory.hasTag(new Tag(\"2\"), \"a\"));\n        }\n    }\n\n    @Test\n    void branchesRetained(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repository = credentials.getHostedRepository();\n            var ref = resetHostedRepository(repository);\n\n            var history = createHistory(repository, ref);\n\n            history.setBranchHash(new Branch(\"1\"), \"a\", new Hash(\"a\"));\n            history.setBranchHash(new Branch(\"2\"), \"a\", new Hash(\"b\"));\n            history.setBranchHash(new Branch(\"1\"), \"a\", new Hash(\"c\"));\n\n            assertEquals(new Hash(\"c\"), history.branchHash(new Branch(\"1\"), \"a\").orElseThrow());\n            assertEquals(new Hash(\"b\"), history.branchHash(new Branch(\"2\"), \"a\").orElseThrow());\n\n            var newHistory = createHistory(repository, ref);\n\n            assertEquals(new Hash(\"c\"), newHistory.branchHash(new Branch(\"1\"), \"a\").orElseThrow());\n            assertEquals(new Hash(\"b\"), newHistory.branchHash(new Branch(\"2\"), \"a\").orElseThrow());\n        }\n    }\n\n    @Test\n    void branchesSeparateUpdaters(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repository = credentials.getHostedRepository();\n            var ref = resetHostedRepository(repository);\n\n            var history = createHistory(repository, ref);\n\n            history.setBranchHash(new Branch(\"1\"), \"a\", new Hash(\"a\"));\n            history.setBranchHash(new Branch(\"2\"), \"a\", new Hash(\"b\"));\n            history.setBranchHash(new Branch(\"1\"), \"b\", new Hash(\"c\"));\n            history.setBranchHash(new Branch(\"2\"), \"a\", new Hash(\"d\"));\n\n            assertEquals(new Hash(\"a\"), history.branchHash(new Branch(\"1\"), \"a\").orElseThrow());\n            assertEquals(new Hash(\"d\"), history.branchHash(new Branch(\"2\"), \"a\").orElseThrow());\n            assertEquals(new Hash(\"c\"), history.branchHash(new Branch(\"1\"), \"b\").orElseThrow());\n\n            var newHistory = createHistory(repository, ref);\n\n            assertEquals(new Hash(\"a\"), newHistory.branchHash(new Branch(\"1\"), \"a\").orElseThrow());\n            assertEquals(new Hash(\"d\"), newHistory.branchHash(new Branch(\"2\"), \"a\").orElseThrow());\n            assertEquals(new Hash(\"c\"), newHistory.branchHash(new Branch(\"1\"), \"b\").orElseThrow());\n        }\n    }\n\n    @Test\n    void tagsSeparateUpdaters(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repository = credentials.getHostedRepository();\n            var ref = resetHostedRepository(repository);\n            var history = createHistory(repository, ref);\n\n            history.addTags(List.of(new Tag(\"1\"), new Tag(\"2\")), \"a\");\n            history.addTags(List.of(new Tag(\"2\"), new Tag(\"3\")), \"b\");\n\n            assertTrue(history.hasTag(new Tag(\"1\"), \"a\"));\n            assertTrue(history.hasTag(new Tag(\"2\"), \"a\"));\n            assertFalse(history.hasTag(new Tag(\"3\"), \"a\"));\n            assertFalse(history.hasTag(new Tag(\"1\"), \"b\"));\n            assertTrue(history.hasTag(new Tag(\"2\"), \"b\"));\n            assertTrue(history.hasTag(new Tag(\"3\"), \"b\"));\n\n            var newHistory = createHistory(repository, ref);\n\n            assertTrue(newHistory.hasTag(new Tag(\"1\"), \"a\"));\n            assertTrue(newHistory.hasTag(new Tag(\"2\"), \"a\"));\n            assertFalse(newHistory.hasTag(new Tag(\"3\"), \"a\"));\n            assertFalse(newHistory.hasTag(new Tag(\"1\"), \"b\"));\n            assertTrue(newHistory.hasTag(new Tag(\"2\"), \"b\"));\n            assertTrue(newHistory.hasTag(new Tag(\"3\"), \"b\"));\n        }\n    }\n\n    @Test\n    void tagsMarkRetry(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repository = credentials.getHostedRepository();\n            var ref = resetHostedRepository(repository);\n            var history = createHistory(repository, ref);\n\n            history.addTags(List.of(new Tag(\"1\"), new Tag(\"2\")), \"a\");\n            history.addTags(List.of(new Tag(\"2\"), new Tag(\"3\")), \"b\");\n\n            history.retryTagUpdate(new Tag(\"1\"), \"a\");\n            history.retryTagUpdate(new Tag(\"2\"), \"b\");\n\n            assertTrue(history.shouldRetryTagUpdate(new Tag(\"1\"), \"a\"));\n            assertFalse(history.shouldRetryTagUpdate(new Tag(\"2\"), \"a\"));\n            assertTrue(history.shouldRetryTagUpdate(new Tag(\"2\"), \"b\"));\n            assertFalse(history.shouldRetryTagUpdate(new Tag(\"3\"), \"b\"));\n\n            var newHistory = createHistory(repository, ref);\n\n            assertTrue(newHistory.shouldRetryTagUpdate(new Tag(\"1\"), \"a\"));\n            assertFalse(newHistory.shouldRetryTagUpdate(new Tag(\"2\"), \"a\"));\n            assertTrue(newHistory.shouldRetryTagUpdate(new Tag(\"2\"), \"b\"));\n            assertFalse(newHistory.shouldRetryTagUpdate(new Tag(\"3\"), \"b\"));\n        }\n    }\n\n    @Test\n    void tagsConcurrentModification(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repository = credentials.getHostedRepository();\n            var ref = resetHostedRepository(repository);\n            var history = createHistory(repository, ref);\n\n            history.addTags(List.of(new Tag(\"1\"), new Tag(\"2\")), \"a\");\n\n            assertTrue(history.hasTag(new Tag(\"1\"), \"a\"));\n            assertTrue(history.hasTag(new Tag(\"2\"), \"a\"));\n\n            var history1 = createHistory(repository, ref);\n            assertTrue(history1.hasTag(new Tag(\"1\"), \"a\"));\n            assertTrue(history1.hasTag(new Tag(\"2\"), \"a\"));\n            assertFalse(history1.hasTag(new Tag(\"3\"), \"a\"));\n            assertFalse(history1.hasTag(new Tag(\"4\"), \"a\"));\n\n            var history2 = createHistory(repository, ref);\n            assertTrue(history2.hasTag(new Tag(\"1\"), \"a\"));\n            assertTrue(history2.hasTag(new Tag(\"2\"), \"a\"));\n            assertFalse(history2.hasTag(new Tag(\"3\"), \"a\"));\n            assertFalse(history2.hasTag(new Tag(\"4\"), \"a\"));\n\n            history1.addTags(Set.of(new Tag(\"3\")), \"a\");\n            history2.addTags(Set.of(new Tag(\"4\")), \"a\");\n\n            assertTrue(history1.hasTag(new Tag(\"3\"), \"a\"));\n            assertFalse(history1.hasTag(new Tag(\"4\"), \"a\"));\n            assertTrue(history2.hasTag(new Tag(\"3\"), \"a\"));\n            assertTrue(history2.hasTag(new Tag(\"4\"), \"a\"));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/test/java/org/openjdk/skara/bots/notify/UpdaterTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.Tag;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.OpenJDKTag;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.util.regex.Pattern;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.notify.TestUtils.*;\n\npublic class UpdaterTests {\n    private static class TestRepositoryListener implements Notifier, RepositoryListener {\n        private final String name;\n        private final boolean idempotent;\n        private int updateCount = 0;\n        private boolean shouldFail = false;\n\n        TestRepositoryListener(String name, boolean idempotent) {\n            this.name = name;\n            this.idempotent = idempotent;\n        }\n\n        @Override\n        public void onNewCommits(HostedRepository repository, Repository localRepository, Path scratchPath, List<Commit> commits,\n                                 Branch branch) throws NonRetriableException {\n            updateCount++;\n            if (shouldFail) {\n                if (idempotent) {\n                    throw new RuntimeException(\"induced failure\");\n                } else {\n                    throw new NonRetriableException(new RuntimeException(\"unretriable failure\"));\n                }\n            }\n        }\n\n        @Override\n        public void onNewOpenJDKTagCommits(HostedRepository repository, Repository localRepository,\n                                           Path scratchPath, List<Commit> commits, OpenJDKTag tag, Tag.Annotated annotated) {\n            throw new RuntimeException(\"unexpected\");\n        }\n\n        @Override\n        public void onNewTagCommit(HostedRepository repository, Repository localRepository, Path scratchPath, Commit commit, Tag tag,\n                                   Tag.Annotated annotation) {\n            throw new RuntimeException(\"unexpected\");\n        }\n\n        @Override\n        public void onNewBranch(HostedRepository repository, Repository localRepository, Path scratchPath, List<Commit> commits,\n                                Branch parent, Branch branch) {\n            throw new RuntimeException(\"unexpected\");\n        }\n\n        @Override\n        public String name() {\n            return name;\n        }\n\n        @Override\n        public boolean idempotent() {\n            return idempotent;\n        }\n\n        @Override\n        public void attachTo(Emitter e) {\n            e.registerRepositoryListener(this);\n        }\n    }\n\n    @Test\n    void testIdempotenceMix(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n\n            var idempotent = new TestRepositoryListener(\"i\", true);\n            idempotent.attachTo(notifyBot);\n\n            var nonIdempotent = new TestRepositoryListener(\"ni\", false);\n            nonIdempotent.attachTo(notifyBot);\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"Fix stuff\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Both updaters should have run\n            assertEquals(2, idempotent.updateCount);\n            assertEquals(2, nonIdempotent.updateCount);\n\n            var nextEditHash = CheckableRepository.appendAndCommit(localRepo, \"Yet another line\", \"Fix more stuff\");\n            localRepo.push(nextEditHash, repo.authenticatedUrl(), \"master\");\n\n            idempotent.shouldFail = true;\n            nonIdempotent.shouldFail = true;\n            assertThrows(RuntimeException.class, () -> TestBotRunner.runPeriodicItems(notifyBot));\n\n            // Both updaters should have run again\n            assertEquals(3, idempotent.updateCount);\n            assertEquals(3, nonIdempotent.updateCount);\n\n            assertThrows(RuntimeException.class, () -> TestBotRunner.runPeriodicItems(notifyBot));\n\n            // But now only the idempotent one should have been retried\n            assertEquals(4, idempotent.updateCount);\n            assertEquals(3, nonIdempotent.updateCount);\n        }\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/test/java/org/openjdk/skara/bots/notify/comment/CommitCommentNotifierTests.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.comment;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.bots.notify.NotifyBot;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.openjdk.skara.bots.notify.TestUtils.*;\n\npublic class CommitCommentNotifierTests {\n    @Test\n    void testCommitComment(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var issueProject = credentials.getIssueProject();\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .integratorId(repo.forge().currentUser().id())\n                                     .build();\n            // Register a RepositoryListener to make history initialize on the first run\n            notifyBot.registerRepositoryListener(new NullRepositoryListener());\n            var notifier = new CommitCommentNotifier(issueProject);\n            notifier.attachTo(notifyBot);\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Save the state\n            var historyState = localRepo.fetch(repo.authenticatedUrl(), \"history\").orElseThrow();\n\n            // Commit a fix\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"Fix an issue\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            var pr = credentials.createPullRequest(repo, \"master\", \"master\", \"Fix an issue\");\n            pr.setBody(\"I made a fix\");\n            pr.addLabel(\"integrated\");\n            pr.addComment(\"More text!\\n\\n@user Pushed as commit \" + editHash.hex() + \". Even more text.\\n\\nAnd some additional text.\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Check commit comment\n            var comments = repo.commitComments(editHash);\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertEquals(editHash, comment.commit());\n            assertEquals(repo.forge().currentUser(), comment.author());\n            assertEquals(Optional.empty(), comment.path());\n            assertEquals(Optional.empty(), comment.line());\n\n            var lines = comment.body().split(\"\\n\");\n            assertEquals(4, lines.length);\n            assertEquals(\"<!-- COMMIT COMMENT NOTIFICATION -->\", lines[0]);\n            assertEquals(\"### Review\", lines[1]);\n            assertEquals(\"\", lines[2]);\n            assertEquals(\"- [\" + repo.name() + \"/\" + pr.id() + \"](\" + pr.webUrl() + \")\", lines[3]);\n        }\n    }\n\n    @Test\n    void testCommitCommentWithIssues(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var issueProject = credentials.getIssueProject();\n            var issue = issueProject.createIssue(\"A title\",\n                                                 List.of(\"A description\"),\n                                                 Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var commitMessageTitle = issue.id() + \": A title\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Change\", commitMessageTitle);\n\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .integratorId(repo.forge().currentUser().id())\n                                     .build();\n            // Register a RepositoryListener to make history initialize on the first run\n            notifyBot.registerRepositoryListener(new NullRepositoryListener());\n            var notifier = new CommitCommentNotifier(issueProject);\n            notifier.attachTo(notifyBot);\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Save the state\n            var historyState = localRepo.fetch(repo.authenticatedUrl(), \"history\").orElseThrow();\n\n            // Commit a fix\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            var pr = credentials.createPullRequest(repo, \"master\", \"master\", commitMessageTitle);\n            pr.setBody(\"\\n\\n### Issue\\n * [\" + issue.id() + \"](http://www.test.test/): The issue\");\n            pr.addLabel(\"integrated\");\n            pr.addComment(\"Pushed as commit \" + editHash.hex() + \".\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Check commit comment\n            var comments = repo.commitComments(editHash);\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertEquals(editHash, comment.commit());\n            assertEquals(repo.forge().currentUser(), comment.author());\n            assertEquals(Optional.empty(), comment.path());\n            assertEquals(Optional.empty(), comment.line());\n\n            var lines = comment.body().split(\"\\n\");\n            assertEquals(8, lines.length);\n            assertEquals(\"<!-- COMMIT COMMENT NOTIFICATION -->\", lines[0]);\n            assertEquals(\"### Review\", lines[1]);\n            assertEquals(\"\", lines[2]);\n            assertEquals(\"- [\" + repo.name() + \"/\" + pr.id() + \"](\" + pr.webUrl() + \")\", lines[3]);\n            assertEquals(\"\", lines[4]);\n            assertEquals(\"### Issues\", lines[5]);\n            assertEquals(\"\", lines[6]);\n            assertEquals(\"- [\" + issue.id() + \"](\" + issue.webUrl() + \")\", lines[7]);\n        }\n    }\n\n    /**\n     * Test that the CommitCommentNotifier never repeates the exact same comment\n     */\n    @Test\n    void testNoRepeatedCommitComment(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var issueProject = credentials.getIssueProject();\n            var notifyBot = NotifyBot.newBuilder()\n                    .repository(repo)\n                    .storagePath(storageFolder)\n                    .branches(Pattern.compile(\"master\"))\n                    .tagStorageBuilder(tagStorage)\n                    .branchStorageBuilder(branchStorage)\n                    .prStateStorageBuilder(prStateStorage)\n                    .integratorId(repo.forge().currentUser().id())\n                    .build();\n            // Register a RepositoryListener to make history initialize on the first run\n            notifyBot.registerRepositoryListener(new NullRepositoryListener());\n            var notifier = new CommitCommentNotifier(issueProject);\n            notifier.attachTo(notifyBot);\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Save the state\n            var historyState = localRepo.fetch(repo.authenticatedUrl(), \"history\").orElseThrow();\n\n            // Commit a fix\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"Fix an issue\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            var pr = credentials.createPullRequest(repo, \"master\", \"master\", \"Fix an issue\");\n            pr.setBody(\"I made a fix\");\n            pr.addLabel(\"integrated\");\n            pr.addComment(\"@user Pushed as commit \" + editHash.hex() + \".\");\n\n            // Run the notifier manually to add a comment\n            notifier.onIntegratedPullRequest(pr, storageFolder, editHash);\n\n            // Run the bot officially\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Check that we only have 1 commit comment\n            var comments = repo.commitComments(editHash);\n            assertEquals(1, comments.size());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/test/java/org/openjdk/skara/bots/notify/issue/IssueNotifierTests.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.issue;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.bots.notify.CommitFormatters;\nimport org.openjdk.skara.bots.notify.NotifyBot;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.Branch;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.notify.TestUtils.*;\nimport static org.openjdk.skara.issuetracker.Issue.State.OPEN;\nimport static org.openjdk.skara.issuetracker.Issue.State.RESOLVED;\nimport static org.openjdk.skara.issuetracker.jira.JiraProject.RESOLVED_IN_BUILD;\nimport static org.openjdk.skara.issuetracker.jira.JiraProject.SUBCOMPONENT;\nimport static org.openjdk.skara.issuetracker.jira.JiraProject.JEP_NUMBER;\n\nimport static org.openjdk.skara.bots.common.PullRequestConstants.*;\n\npublic class IssueNotifierTests {\n    private static final String pullRequestTip = \"A pull request was submitted for review.\";\n\n    private Set<String> fixVersions(IssueTrackerIssue issue) {\n        if (!issue.properties().containsKey(\"fixVersions\")) {\n            return Set.of();\n        }\n        return issue.properties().get(\"fixVersions\").stream()\n                    .map(JSONValue::asString)\n                    .collect(Collectors.toSet());\n    }\n\n    private TestBotFactory.TestBotFactoryBuilder testBotBuilderFactory(HostedRepository hostedRepository, IssueProject issueProject, Path storagePath, JSONObject notifierConfig) throws IOException {\n        if (!notifierConfig.contains(\"project\")) {\n            notifierConfig.put(\"project\", \"issueproject\");\n        }\n        return TestBotFactory.newBuilder()\n                             .addHostedRepository(\"hostedrepo\", hostedRepository)\n                             .addIssueProject(\"issueproject\", issueProject)\n                             .storagePath(storagePath)\n                             .addConfiguration(\"database\", JSON.object()\n                                                               .put(\"repository\", \"hostedrepo:history\")\n                                                               .put(\"name\", \"duke\")\n                                                               .put(\"email\", \"duke@openjdk.org\"))\n                             .addConfiguration(\"ready\", JSON.object()\n                                                            .put(\"labels\", JSON.array())\n                                                            .put(\"comments\", JSON.array()))\n                             .addConfiguration(\"integrator\", JSON.of(hostedRepository.forge().currentUser().id()))\n                             .addConfiguration(\"repositories\", JSON.object()\n                                                                   .put(\"hostedrepo\", JSON.object()\n                                                                                          .put(\"basename\", \"test\")\n                                                                                          .put(\"branches\", \"master|other|other2\")\n                                                                                          .put(\"issue\", notifierConfig)));\n    }\n\n    private TestBotFactory testBotBuilder(HostedRepository hostedRepository, IssueProject issueProject, Path storagePath, JSONObject notifierConfig) throws IOException {\n        return testBotBuilderFactory(hostedRepository, issueProject, storagePath, notifierConfig).build();\n    }\n\n    @Test\n    void testIssueLinkIdempotence(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var issueProject = credentials.getIssueProject();\n            var commitIcon = URI.create(\"http://www.example.com/commit.png\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .integratorId(repo.forge().currentUser().id())\n                                     .build();\n            var updater = IssueNotifier.newBuilder()\n                                      .issueProject(issueProject)\n                                      .reviewLink(false)\n                                      .commitIcon(commitIcon)\n                                      .build();\n            // Register a RepositoryListener to make history initialize on the first run\n            notifyBot.registerRepositoryListener(new NullRepositoryListener());\n            updater.attachTo(notifyBot);\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Save the state\n            var historyState = localRepo.fetch(repo.authenticatedUrl(), \"history\").orElseThrow();\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            var pr = credentials.createPullRequest(repo, \"master\", \"master\", issue.id() + \": Fix that issue\");\n            pr.setBody(\"\\n\\n### Issue\\n * [\" + issue.id() + \"](http://www.test.test/): The issue\");\n            pr.addLabel(\"integrated\");\n            pr.addComment(\"Pushed as commit \" + editHash.hex() + \".\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in a link\n            var links = issue.links();\n            assertEquals(1, links.size());\n            var link = links.get(0);\n            assertEquals(commitIcon, link.iconUrl().orElseThrow());\n            assertEquals(\"Commit(master)\", link.title().orElseThrow());\n            assertEquals(repo.webUrl(editHash), link.uri().orElseThrow());\n\n            // Wipe the history\n            localRepo.push(historyState, repo.authenticatedUrl(), \"history\", true);\n\n            // Run it again\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // There should be no new links\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(1, updatedIssue.links().size());\n        }\n    }\n\n    @Test\n    void testPullRequest(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var issueProject = credentials.getIssueProject();\n            var reviewIcon = URI.create(\"http://www.example.com/review.png\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .readyComments(Map.of(reviewer.forge().currentUser().username(), Pattern.compile(\"This is now ready\")))\n                                     .build();\n            var updater = IssueNotifier.newBuilder()\n                                      .issueProject(issueProject)\n                                      .reviewIcon(reviewIcon)\n                                      .commitLink(false)\n                                      .build();\n            updater.attachTo(notifyBot);\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and a pull request to fix it\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"Fix that issue\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(repo, \"edit\", \"master\", issue.id() + \": Fix that issue\");\n            pr.setBody(\"\\n\\n### Issue\\n * [\" + issue.id() + \"](http://www.test.test/): The issue\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The issue should not yet contain a link to the PR or a comment which contains the link to the PR\n            var links = issue.links();\n            assertEquals(0, links.size());\n            var comments = issue.comments();\n            assertEquals(0, comments.size());\n\n            // Just a label isn't enough\n            pr.addLabel(\"rfr\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n            links = issue.links();\n            assertEquals(0, links.size());\n            comments = issue.comments();\n            assertEquals(0, comments.size());\n\n            // Neither is just a comment\n            pr.removeLabel(\"rfr\");\n            var reviewPr = reviewer.pullRequest(pr.id());\n            reviewPr.addComment(\"This is now ready\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n            links = issue.links();\n            assertEquals(0, links.size());\n            comments = issue.comments();\n            assertEquals(0, comments.size());\n\n            // Both are needed\n            pr.addLabel(\"rfr\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The issue should now contain a link to the PR and a comment which contains the link to the PR\n            links = issue.links();\n            assertEquals(1, links.size());\n            assertEquals(pr.webUrl(), links.get(0).uri().orElseThrow());\n            assertEquals(reviewIcon, links.get(0).iconUrl().orElseThrow());\n            comments = issue.comments();\n            assertEquals(1, comments.size());\n            assertTrue(comments.get(0).body().contains(pullRequestTip));\n            assertTrue(comments.get(0).body().contains(pr.webUrl().toString()));\n\n            // Add another issue\n            var issue2 = issueProject.createIssue(\"This is another issue\", List.of(\"Yes indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            pr.setBody(\"\\n\\n### Issues\\n * [\" + issue.id() + \"](http://www.test.test/): The issue\\n * [\" + issue2.id() +\n                    \"](http://www.test2.test/): The second issue\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Both issues should contain a link to the PR and a comment which contains the link to the PR\n            var links1 = issue.links();\n            assertEquals(1, links1.size());\n            assertEquals(pr.webUrl(), links1.get(0).uri().orElseThrow());\n            var comments1 = issue.comments();\n            assertEquals(1, comments1.size());\n            assertTrue(comments1.get(0).body().contains(pullRequestTip));\n            assertTrue(comments1.get(0).body().contains(pr.webUrl().toString()));\n\n            var links2 = issue2.links();\n            assertEquals(1, links2.size());\n            assertEquals(pr.webUrl(), links2.get(0).uri().orElseThrow());\n            var comments2 = issue2.comments();\n            assertEquals(1, comments2.size());\n            assertTrue(comments2.get(0).body().contains(pullRequestTip));\n            assertTrue(comments2.get(0).body().contains(pr.webUrl().toString()));\n\n            // Drop the first one\n            pr.setBody(\"\\n\\n### Issues\\n * [\" + issue2.id() + \"](http://www.test2.test/): That other issue\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Only the second issue should now contain a link to the PR and a comment which contains the link to the PR\n            links1 = issue.links();\n            assertEquals(0, links1.size());\n            comments1 = issue.comments();\n            assertEquals(0, comments1.size());\n\n            links2 = issue2.links();\n            assertEquals(1, links2.size());\n            assertEquals(pr.webUrl(), links2.get(0).uri().orElseThrow());\n            comments2 = issue2.comments();\n            assertEquals(1, comments2.size());\n            assertTrue(comments2.get(0).body().contains(pullRequestTip));\n            assertTrue(comments2.get(0).body().contains(pr.webUrl().toString()));\n\n            // test line separator \"\\r\"\n            pr.setBody(\"\\r\\r### Issues\\r * [\" + issue.id() + \"](http://www.test.test/): The issue\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Only the first issue should now contain a link to the PR and a comment which contains the link to the PR\n            links1 = issue.links();\n            assertEquals(1, links1.size());\n            assertEquals(pr.webUrl(), links1.get(0).uri().orElseThrow());\n            comments1 = issue.comments();\n            assertEquals(1, comments1.size());\n            assertTrue(comments1.get(0).body().contains(pullRequestTip));\n            assertTrue(comments1.get(0).body().contains(pr.webUrl().toString()));\n\n            links2 = issue2.links();\n            assertEquals(0, links2.size());\n            comments2 = issue2.comments();\n            assertEquals(0, comments2.size());\n\n            // test line separator \"\\r\\n\"\n            pr.setBody(\"\\r\\n\\r\\n### Issues\\r\\n * [\" + issue2.id() + \"](http://www.test2.test/): That other issue\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Only the second issue should now contain a link to the PR and a comment which contains the link to the PR\n            links1 = issue.links();\n            assertEquals(0, links1.size());\n            comments1 = issue.comments();\n            assertEquals(0, comments1.size());\n\n            links2 = issue2.links();\n            assertEquals(1, links2.size());\n            assertEquals(pr.webUrl(), links2.get(0).uri().orElseThrow());\n            comments2 = issue2.comments();\n            assertEquals(1, comments2.size());\n            assertTrue(comments2.get(0).body().contains(pullRequestTip));\n            assertTrue(comments2.get(0).body().contains(pr.webUrl().toString()));\n        }\n    }\n\n    @Test\n    void testPullRequestNoReview(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var issueProject = credentials.getIssueProject();\n            var reviewIcon = URI.create(\"http://www.example.com/review.png\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .readyComments(Map.of(reviewer.forge().currentUser().username(), Pattern.compile(\"This is now ready\")))\n                                     .build();\n            var updater = IssueNotifier.newBuilder()\n                                      .issueProject(issueProject)\n                                      .reviewLink(false)\n                                      .reviewIcon(reviewIcon)\n                                      .commitLink(false)\n                                      .build();\n            updater.attachTo(notifyBot);\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and a pull request to fix it\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"Fix that issue\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(repo, \"edit\", \"master\", issue.id() + \": Fix that issue\");\n            pr.setBody(\"\\n\\n### Issue\\n * [\" + issue.id() + \"](http://www.test.test/): The issue\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Add required label\n            pr.addLabel(\"rfr\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // And the required comment\n            var reviewPr = reviewer.pullRequest(pr.id());\n            reviewPr.addComment(\"This is now ready\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The issue should still not contain a link to the PR or a comment which contains the link to the PR\n            var links = issue.links();\n            assertEquals(0, links.size());\n            var comments = issue.comments();\n            assertEquals(1, comments.size());\n            assertTrue(comments.get(0).body().contains(pullRequestTip));\n            assertTrue(comments.get(0).body().contains(pr.webUrl().toString()));\n        }\n    }\n\n    @Test\n    void testCsrIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, JSON.object()).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and its csr issue.\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var csrIssue = issueProject.createIssue(\"This is a csr issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"CSR\")));\n            issue.addLink(Link.create(csrIssue, \"csr for\").build());\n            var withdrawnCsrIssue = issueProject.createIssue(\"This is a withdrawn csr issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"CSR\")));\n            issue.addLink(Link.create(withdrawnCsrIssue, \"csr for\").build());\n\n            // Push a commit and create a pull request\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\",\n                            issue.id() + \": This is an issue\\n\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            var pr = credentials.createPullRequest(repo, \"edit\", \"master\", issue.id() + \": This is an issue\");\n            pr.setBody(\"\\n\\n### Issues\\n\" +\n                    \" * [\" + issue.id() + \"](http://www.test.test/): This is an issue\\n\" +\n                    \" * [\" + csrIssue.id() + \"](http://www.test2.test/): This is a csr issue (**CSR**)\\n\" +\n                    \" * [\" + withdrawnCsrIssue.id() + \"](http://www.test3.test/): This is a withdrawn csr issue (**CSR**) (Withdrawn)\\n\"\n            );\n            pr.addLabel(\"rfr\");\n            pr.addComment(\"This is now ready\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Get the issues.\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            var updatedCsrIssue = issueProject.issue(csrIssue.id()).orElseThrow();\n            var updatedWithdrawnCsrIssue = issueProject.issue(withdrawnCsrIssue.id()).orElseThrow();\n\n            // Non-csr issue should have the PR link and PR comment.\n            var issueLinks = updatedIssue.links();\n            assertEquals(3, issueLinks.size());\n            assertEquals(\"csr for\", issueLinks.get(0).relationship().orElseThrow());\n            assertEquals(\"csr for\", issueLinks.get(1).relationship().orElseThrow());\n            assertEquals(pr.webUrl(), issueLinks.get(2).uri().orElseThrow());\n\n            var issueComments = updatedIssue.comments();\n            assertEquals(1, issueComments.size());\n            assertTrue(issueComments.get(0).body().contains(pullRequestTip));\n            assertTrue(issueComments.get(0).body().contains(pr.webUrl().toString()));\n\n            // csr issue shouldn't have the PR link or PR comment.\n            var csrIssueLinks = updatedCsrIssue.links();\n            assertEquals(1, csrIssueLinks.size());\n            assertEquals(\"csr of\", csrIssueLinks.get(0).relationship().orElseThrow());\n\n            var csrIssueComments = updatedCsrIssue.comments();\n            assertEquals(0, csrIssueComments.size());\n\n            // Withdrawn csr issue shouldn't have the PR link or PR comment.\n            var withdrawnCsrIssueLinks = updatedWithdrawnCsrIssue.links();\n            assertEquals(1, withdrawnCsrIssueLinks.size());\n            assertEquals(\"csr of\", withdrawnCsrIssueLinks.get(0).relationship().orElseThrow());\n\n            var withdrawnCsrIssueComments = updatedWithdrawnCsrIssue.comments();\n            assertEquals(0, withdrawnCsrIssueComments.size());\n        }\n    }\n\n    @Test\n    void testJepIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, JSON.object()).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and a jep issue.\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Issue body\"),\n                    Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var jepIssue = issueProject.createIssue(\"This is a jep\", List.of(\"Jep body\"),\n                    Map.of(\"issuetype\", JSON.of(\"JEP\"), \"status\", JSON.object().put(\"name\", \"Submitted\"), JEP_NUMBER, JSON.of(\"123\")));\n\n            // Push a commit and create a pull request\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\",\n                    issue.id() + \": This is an issue\\n\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            var pr = credentials.createPullRequest(repo, \"edit\", \"master\", issue.id() + \": This is an issue\");\n            pr.setBody(\"\\n\\n### Issues\\n\" +\n                    \" * [\" + issue.id() + \"](http://www.test.test/): This is an issue\\n\" +\n                    \" * [\" + jepIssue.id() + \"](http://www.test2.test/): This is a jep (**JEP**)\");\n            pr.addLabel(\"rfr\");\n            pr.addComment(\"This is now ready\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Get the issues.\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            var updatedJepIssue = issueProject.issue(jepIssue.id()).orElseThrow();\n\n            // Non-jep issue should have the PR link and PR comment.\n            var issueLinks = updatedIssue.links();\n            assertEquals(1, issueLinks.size());\n            assertEquals(pr.webUrl(), issueLinks.get(0).uri().orElseThrow());\n\n            var issueComments = updatedIssue.comments();\n            assertEquals(1, issueComments.size());\n            assertTrue(issueComments.get(0).body().contains(pullRequestTip));\n            assertTrue(issueComments.get(0).body().contains(pr.webUrl().toString()));\n\n            // jep issue shouldn't have the PR link or PR comment.\n            var jepIssueLinks = updatedJepIssue.links();\n            assertEquals(0, jepIssueLinks.size());\n\n            var jepIssueComments = updatedJepIssue.comments();\n            assertEquals(0, jepIssueComments.size());\n        }\n    }\n\n    @Test\n    void testPullRequestPROnly(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var reviewIcon = URI.create(\"http://www.example.com/review.png\");\n            var jbsNotifierConfig = JSON.object().put(\"reviews\", JSON.object().put(\"icon\", reviewIcon.toString()));\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            localRepo.push(localRepo.resolve(\"master\").orElseThrow(), repo.authenticatedUrl(), \"other\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and a pull request to fix it\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(repo, \"other\", \"edit\", issue.id() + \": Fix that issue\");\n            pr.setBody(\"\\n\\n### Issue\\n * [\" + issue.id() + \"](http://www.test.test/): The issue\");\n            pr.addLabel(\"rfr\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The issue should now contain a link to the PR and a comment which contains the link to the PR\n            var links = issue.links();\n            assertEquals(1, links.size());\n            assertEquals(pr.webUrl(), links.get(0).uri().orElseThrow());\n            assertEquals(reviewIcon, links.get(0).iconUrl().orElseThrow());\n            var comments = issue.comments();\n            assertEquals(1, comments.size());\n            assertTrue(comments.get(0).body().contains(pullRequestTip));\n            assertTrue(comments.get(0).body().contains(pr.webUrl().toString()));\n\n            // Simulate integration\n            pr.addComment(\"Pushed as commit \" + editHash.hex() + \".\");\n            pr.addLabel(\"integrated\");\n            pr.setState(Issue.State.CLOSED);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"other\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in another link\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            links = updatedIssue.links();\n            assertEquals(2, links.size());\n\n            // The issue should only contain a comment which contains the link to the PR\n            comments = updatedIssue.comments();\n            assertEquals(1, comments.size());\n            assertTrue(comments.get(0).body().contains(pullRequestTip));\n            assertTrue(comments.get(0).body().contains(pr.webUrl().toString()));\n\n            // Now simulate a merge to another branch\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // No additional link should have been created\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            links = updatedIssue.links();\n            assertEquals(2, links.size());\n\n            // The issue should only contain a comment which contains the link to the PR\n            comments = updatedIssue.comments();\n            assertEquals(1, comments.size());\n            assertTrue(comments.get(0).body().contains(pullRequestTip));\n            assertTrue(comments.get(0).body().contains(pr.webUrl().toString()));\n        }\n    }\n\n    @Test\n    void testMultipleIssues(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object())\n                                        .put(\"buildname\", \"team\");\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var issue1 = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var issue2 = issueProject.createIssue(\"This is another issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var issue3 = issueProject.createIssue(\"This is yet another issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\",\n                                                               issue1.id() + \": Fix that issue\\n\" +\n                                                                       issue1.id() + \": Fix that issue\\n\" +\n                                                                       issue2.id() + \": And fix the other issue\\n\" +\n                                                                       issue3.id() + \": As well as this one\\n\",\n                                                               \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in a comment\n            var updatedIssue1 = issueProject.issue(issue1.id()).orElseThrow();\n            var updatedIssue2 = issueProject.issue(issue2.id()).orElseThrow();\n            var updatedIssue3 = issueProject.issue(issue3.id()).orElseThrow();\n\n            var comments1 = updatedIssue1.comments();\n            assertEquals(1, comments1.size());\n            var comment1 = comments1.get(0);\n            assertTrue(comment1.body().contains(editHash.toString()));\n            var comments2 = updatedIssue2.comments();\n            assertEquals(1, comments2.size());\n            var comment2 = comments2.get(0);\n            assertTrue(comment2.body().contains(editHash.toString()));\n            var comments3 = updatedIssue3.comments();\n            assertEquals(1, comments3.size());\n            var comment3 = comments3.get(0);\n            assertTrue(comment3.body().contains(editHash.toString()));\n\n            // As well as a fixVersion and a resolved in build\n            assertEquals(Set.of(\"0.1\"), fixVersions(updatedIssue1));\n            assertEquals(\"team\", updatedIssue1.properties().get(RESOLVED_IN_BUILD).asString());\n            assertEquals(Set.of(\"0.1\"), fixVersions(updatedIssue2));\n            assertEquals(\"team\", updatedIssue2.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // The issue should be assigned and resolved\n            assertEquals(RESOLVED, updatedIssue1.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue1.assignees());\n            assertEquals(RESOLVED, updatedIssue2.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue2.assignees());\n        }\n    }\n\n    @Test\n    void testIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object())\n                                        .put(\"buildname\", \"team\");\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in a comment\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n\n            var comments = updatedIssue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n\n            // As well as a fixVersion and a resolved in build\n            assertEquals(Set.of(\"0.1\"), fixVersions(updatedIssue));\n            assertEquals(\"team\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // The issue should be assigned and resolved\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue.assignees());\n        }\n    }\n\n    @Test\n    void testIssueBuildAfterMerge(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object())\n                                        .put(\"buildname\", \"team\");\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n            var jbsNotifierConfig2 = JSON.object().put(\"fixversions\", JSON.object())\n                                        .put(\"buildname\", \"master\");\n            var notifyBot2 = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig2).create(\"notify\", JSON.object());\n            var jbsNotifierConfig3 = JSON.object().put(\"fixversions\", JSON.object())\n                                         .put(\"buildname\", \"b04\");\n            var notifyBot3 = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig3).create(\"notify\", JSON.object());\n            var jbsNotifierConfig4 = JSON.object().put(\"fixversions\", JSON.object())\n                                         .put(\"buildname\", \"b02\");\n            var notifyBot4 = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig4).create(\"notify\", JSON.object());\n            var jbsNotifierConfig5 = JSON.object().put(\"fixversions\", JSON.object())\n                                         .put(\"buildname\", \"b03\");\n            var notifyBot5 = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig5).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n            var blankHistory = repo.branchHash(\"history\").orElseThrow();\n\n            // Create an issue and commit a fix\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in a comment\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n\n            var comments = updatedIssue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n\n            // As well as a fixVersion and a resolved in build\n            assertEquals(Set.of(\"0.1\"), fixVersions(updatedIssue));\n            assertEquals(\"team\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // The issue should be assigned and resolved\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue.assignees());\n\n            // Restore the history to simulate looking at another repository\n            localRepo.fetch(repo.authenticatedUrl(), \"history\").orElseThrow();\n            localRepo.push(blankHistory, repo.authenticatedUrl(), \"history\", true);\n\n            // When the second notifier sees it, it should upgrade the build name\n            TestBotRunner.runPeriodicItems(notifyBot2);\n\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(\"master\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // Restore the history to simulate looking at another repository\n            localRepo.fetch(repo.authenticatedUrl(), \"history\").orElseThrow();\n            localRepo.push(blankHistory, repo.authenticatedUrl(), \"history\", true);\n\n            // When the third notifier sees it, it should switch to a build number\n            TestBotRunner.runPeriodicItems(notifyBot3);\n\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(\"b04\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // Restore the history to simulate looking at another repository\n            localRepo.fetch(repo.authenticatedUrl(), \"history\").orElseThrow();\n            localRepo.push(blankHistory, repo.authenticatedUrl(), \"history\", true);\n\n            // When the fourth notifier sees it, it should switch to a lower build number\n            TestBotRunner.runPeriodicItems(notifyBot4);\n\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(\"b02\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // Restore the history to simulate looking at another repository\n            localRepo.fetch(repo.authenticatedUrl(), \"history\").orElseThrow();\n            localRepo.push(blankHistory, repo.authenticatedUrl(), \"history\", true);\n\n            // When the fifth notifier sees it, it should NOT switch to a higher build number\n            TestBotRunner.runPeriodicItems(notifyBot5);\n\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(\"b02\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n        }\n    }\n\n    @Test\n    void testIssueBuildAfterTag(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object().put(\"master\", \"16\"))\n                                        .put(\"buildname\", \"team\");\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            var current = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.tag(current, \"jdk-16+9\", \"First tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in a comment\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n\n            var comments = updatedIssue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n\n            // As well as a fixVersion and a resolved in build\n            assertEquals(Set.of(\"16\"), fixVersions(updatedIssue));\n            assertEquals(\"team\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // The issue should be assigned and resolved\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue.assignees());\n\n            // Tag it\n            localRepo.tag(editHash, \"jdk-16+110\", \"Second tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The build should now be updated\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(\"b110\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // Tag it again\n            localRepo.tag(editHash, \"jdk-16+10\", \"Third tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The build should now be updated\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(\"b10\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // Tag it once again\n            localRepo.tag(editHash, \"jdk-16+8\", \"Fourth tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The build should now be updated\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(\"b08\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n        }\n    }\n\n    @Test\n    void testIssueBuildAfterTagMultipleBranches(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object()\n                            .put(\"master\", \"16-foo\")\n                            .put(\"other\", \"16.0.2\"))\n                    .put(\"buildname\", \"team\")\n                    .put(\"tag\", JSON.object().put(\"ignoreopt\", JSON.array().add(\"foo\")));\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            var current = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(current, repo.authenticatedUrl(), \"other\");\n            localRepo.tag(current, \"jdk-16+9\", \"First tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            issue.setProperty(\"fixVersions\", JSON.of(\"16.0.2\"));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"other\");\n            // Add an extra branch that is not configured with any fixVersion\n            localRepo.push(editHash, repo.authenticatedUrl(), \"extra\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in a comment in the issue and in a new backport\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            var backportIssue = updatedIssue.links().get(0).issue().orElseThrow();\n\n            var comments = updatedIssue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n\n            var backportComments = backportIssue.comments();\n            assertEquals(1, backportComments.size());\n            var backportComment = backportComments.get(0);\n            assertTrue(backportComment.body().contains(editHash.toString()));\n\n            // As well as a fixVersion and a resolved in build\n            assertEquals(Set.of(\"16.0.2\"), fixVersions(updatedIssue));\n            assertEquals(\"team\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n            assertEquals(Set.of(\"16-foo\"), fixVersions(backportIssue));\n            assertEquals(\"team\", backportIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // The issue should be assigned and resolved\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue.assignees());\n            assertEquals(RESOLVED, backportIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), backportIssue.assignees());\n\n            // Tag it\n            localRepo.tag(editHash, \"jdk-16+110\", \"Second tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The build should now be updated\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            backportIssue = issueProject.issue(backportIssue.id()).orElseThrow();\n            assertEquals(\"b110\", backportIssue.properties().get(RESOLVED_IN_BUILD).asString());\n            // But not in the update backport\n            assertEquals(\"team\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // Tag it again\n            localRepo.tag(editHash, \"jdk-16+10\", \"Third tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The build should now be updated\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            backportIssue = issueProject.issue(backportIssue.id()).orElseThrow();\n            assertEquals(\"b10\", backportIssue.properties().get(RESOLVED_IN_BUILD).asString());\n            assertEquals(\"team\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // Tag it once again\n            localRepo.tag(editHash, \"jdk-16+8\", \"Fourth tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The build should now be updated\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            backportIssue = issueProject.issue(backportIssue.id()).orElseThrow();\n            assertEquals(\"b08\", backportIssue.properties().get(RESOLVED_IN_BUILD).asString());\n            assertEquals(\"team\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n        }\n    }\n\n    @Test\n    void testTagIgnorePrefixAndOpt(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object()\n                            .put(\"master\", \"foo16-bar\"))\n                    .put(\"buildname\", \"team\")\n                    .put(\"tag\", JSON.object().put(\"ignoreopt\", JSON.array().add(\"bar\")));\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            var current = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(current, repo.authenticatedUrl(), \"other\");\n            localRepo.tag(current, \"jdk-16+9\", \"First tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            issue.setProperty(\"fixVersions\", JSON.of(\"foo16-bar\"));\n            issue.setState(RESOLVED);\n            issue.setProperty(RESOLVED_IN_BUILD, JSON.of(\"master\"));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n\n            // Tag it\n            localRepo.tag(editHash, \"16+1\", \"Second tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The build should now be updated\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(\"b01\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n        }\n    }\n\n    @Test\n    void testIssueBuildAfterTagOpenjdk8u(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object()\n                            .put(\"master\", \"openjdk8u352\")\n                            .put(\"other\", \"openjdk8u342\"))\n                    .put(\"buildname\", \"team\");\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            var current = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(current, repo.authenticatedUrl(), \"other\");\n            localRepo.tag(current, \"jdk8u342-b00\", \"First tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.tag(current, \"jdk9u352-b00\", \"First unrelated tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            issue.setProperty(\"fixVersions\", JSON.of(\"openjdk8u352\"));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"other\");\n            // Add an extra branch that is not configured with any fixVersion\n            localRepo.push(editHash, repo.authenticatedUrl(), \"extra\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in a comment in the issue and in a new backport\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            var backportIssue = updatedIssue.links().get(0).issue().orElseThrow();\n\n            var comments = updatedIssue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n\n            var backportComments = backportIssue.comments();\n            assertEquals(1, backportComments.size());\n            var backportComment = backportComments.get(0);\n            assertTrue(backportComment.body().contains(editHash.toString()));\n\n            // As well as a fixVersion and a resolved in build\n            assertEquals(Set.of(\"openjdk8u352\"), fixVersions(updatedIssue));\n            assertEquals(\"team\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n            assertEquals(Set.of(\"openjdk8u342\"), fixVersions(backportIssue));\n            assertEquals(\"team\", backportIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // The issue should be assigned and resolved\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue.assignees());\n            assertEquals(RESOLVED, backportIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), backportIssue.assignees());\n\n            // Tag it\n            localRepo.tag(editHash, \"jdk8u342-b01\", \"Second tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The build should now be updated\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            backportIssue = issueProject.issue(backportIssue.id()).orElseThrow();\n            assertEquals(\"b01\", backportIssue.properties().get(RESOLVED_IN_BUILD).asString());\n            // But not in the update backport\n            assertEquals(\"team\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // Tag it with an unrelated tag\n            localRepo.tag(editHash, \"jdk9u352-b01\", \"Second tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The build should not change\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            backportIssue = issueProject.issue(backportIssue.id()).orElseThrow();\n            assertEquals(\"b01\", backportIssue.properties().get(RESOLVED_IN_BUILD).asString());\n            // But not in the update backport\n            assertEquals(\"team\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n        }\n    }\n\n    @Test\n    void testIssueBuildAfterTagJdk8uSuffix(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object()\n                            .put(\"maste.\", \"8u341\")\n                            .put(\"othe.\", \"8u341-foo\"))\n                    .put(\"buildname\", \"team\");\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            var current = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(current, repo.authenticatedUrl(), \"other\");\n            localRepo.tag(current, \"jdk8u341-b00\", \"First tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.tag(current, \"jdk8u341-foo-b00\", \"First foo tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            issue.setProperty(\"fixVersions\", JSON.of(\"8u341\"));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"other\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in a comment in the issue and in a new backport\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            var backportIssue = updatedIssue.links().get(0).issue().orElseThrow();\n\n            var comments = updatedIssue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n\n            var backportComments = backportIssue.comments();\n            assertEquals(1, backportComments.size());\n            var backportComment = backportComments.get(0);\n            assertTrue(backportComment.body().contains(editHash.toString()));\n\n            // As well as a fixVersion and a resolved in build\n            assertEquals(Set.of(\"8u341\"), fixVersions(updatedIssue));\n            assertEquals(\"team\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n            assertEquals(Set.of(\"8u341-foo\"), fixVersions(backportIssue));\n            assertEquals(\"team\", backportIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // The issue should be assigned and resolved\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue.assignees());\n            assertEquals(RESOLVED, backportIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), backportIssue.assignees());\n\n            // Tag it\n            localRepo.tag(editHash, \"jdk8u341-b01\", \"Second tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The build should now be updated\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            backportIssue = issueProject.issue(backportIssue.id()).orElseThrow();\n            assertEquals(\"b01\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n            // But not in the update backport\n            assertEquals(\"team\", backportIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // Tag it with a properly formatted tag for the foo version\n            localRepo.tag(editHash, \"jdk8u341-foo-b01\", \"Second foo tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The build should now be updated\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            backportIssue = issueProject.issue(backportIssue.id()).orElseThrow();\n            assertEquals(\"b01\", backportIssue.properties().get(RESOLVED_IN_BUILD).asString());\n        }\n    }\n\n    /**\n     * Tests the optional functionality of requiring a version prefix to be matched\n     * when evaluating tags against fixVersions\n     */\n    @Test\n    void testIssueBuildAfterTagJdk8uPrefix(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object()\n                            .put(\"master\", \"8u341\")\n                            .put(\"other\", \"foo8u341\"))\n                    .put(\"buildname\", \"team\")\n                    .put(\"tag\", JSON.object()\n                            .put(\"matchprefix\", true));\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            var current = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(current, repo.authenticatedUrl(), \"other\");\n            localRepo.tag(current, \"jdk8u341-b00\", \"First tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.tag(current, \"foo8u341-b00\", \"First foo tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            issue.setProperty(\"fixVersions\", JSON.of(\"8u341\"));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"other\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in a comment in the issue and in a new backport\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            var backportIssue = updatedIssue.links().get(0).issue().orElseThrow();\n\n            var comments = updatedIssue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n\n            var backportComments = backportIssue.comments();\n            assertEquals(1, backportComments.size());\n            var backportComment = backportComments.get(0);\n            assertTrue(backportComment.body().contains(editHash.toString()));\n\n            // As well as a fixVersion and a resolved in build\n            assertEquals(Set.of(\"8u341\"), fixVersions(updatedIssue));\n            assertEquals(\"team\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n            assertEquals(Set.of(\"foo8u341\"), fixVersions(backportIssue));\n            assertEquals(\"team\", backportIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // The issue should be assigned and resolved\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue.assignees());\n            assertEquals(RESOLVED, backportIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), backportIssue.assignees());\n\n            // Tag it\n            localRepo.tag(editHash, \"jdk8u341-b01\", \"Second tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The notifier requires prefix to be matched, but the default tag prefix of \"jdk\"\n            // can't be overridden, so fixVersion \"8u341\" does still match tag \"jdk8u341-b01\".\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            backportIssue = issueProject.issue(backportIssue.id()).orElseThrow();\n            assertEquals(\"b01\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n            // But not in the update backport\n            assertEquals(\"team\", backportIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // Tag it with a properly formatted tag for the foo version\n            localRepo.tag(editHash, \"foo8u341-b02\", \"Second foo tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The build should now be updated\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            backportIssue = issueProject.issue(backportIssue.id()).orElseThrow();\n            assertEquals(\"b02\", backportIssue.properties().get(RESOLVED_IN_BUILD).asString());\n            // And the main issue should stay the same\n            assertEquals(\"b01\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n        }\n    }\n\n    @Test\n    void testIssueRetryTag(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object().put(\"master\", \"16\"))\n                                        .put(\"buildname\", \"team\");\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            var current = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.tag(current, \"jdk-16+9\", \"First tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in a comment\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n\n            var comments = updatedIssue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n\n            // As well as a fixVersion and a resolved in build\n            assertEquals(Set.of(\"16\"), fixVersions(updatedIssue));\n            assertEquals(\"team\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // The issue should be assigned and resolved\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue.assignees());\n\n            // Tag it\n            localRepo.tag(editHash, \"jdk-16+110\", \"Second tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The build should now be updated\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(\"b110\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // Tag it again\n            localRepo.tag(editHash, \"jdk-16+10\", \"Third tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n\n            // Claim that it is already processed\n            localRepo.fetch(repo.authenticatedUrl(), \"+history:history\").orElseThrow();\n            localRepo.checkout(new Branch(\"history\"), true);\n            var historyFile = repoFolder.resolve(\"test.tags.txt\");\n            var processed = new ArrayList<>(Files.readAllLines(historyFile));\n            processed.add(\"jdk-16+10 issue done\");\n            Files.writeString(historyFile, String.join(\"\\n\", processed));\n            localRepo.add(historyFile);\n            var updatedHash = localRepo.commit(\"Marking jdk-16+10 as done\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(updatedHash, repo.authenticatedUrl(), \"history\");\n\n            // Now let the notifier see it\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The build should not have been updated\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(\"b110\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // Flag it as in need of retry\n            processed.removeLast();\n            processed.add(\"jdk-16+10 issue retry\");\n            Files.writeString(repoFolder.resolve(\"test.tags.txt\"), String.join(\"\\n\", processed));\n            localRepo.add(historyFile);\n            var retryHash = localRepo.commit(\"Marking jdk-16+10 as needing retry\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(retryHash, repo.authenticatedUrl(), \"history\");\n\n            // Now let the notifier see it\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The build should have been updated by the retry\n            updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(\"b10\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n        }\n    }\n\n    @Test\n    void testIssueOtherDomain(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(issueProject.issueTracker().currentUser().username());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object())\n                                        .put(\"census\", JSON.of(\"census:master\"))\n                                        .put(\"namespace\", \"test\");\n            var notifyBotFactory = testBotBuilderFactory(repo, issueProject, storageFolder, jbsNotifierConfig);\n            notifyBotFactory.addHostedRepository(\"census\", censusBuilder.build());\n            var notifyBot = notifyBotFactory.build().create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var authorEmailAddress = \"integrationreviewer1@otherjdk.org\";\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in a comment\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n\n            var comments = updatedIssue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n\n            // As well as a fixVersion\n            assertEquals(Set.of(\"0.1\"), fixVersions(updatedIssue));\n\n            // The issue should be assigned and resolved\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue.assignees());\n        }\n    }\n\n    @Test\n    void testIssueNoVersion(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), null);\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object());\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in a comment\n            var comments = issue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n\n            // But not in the fixVersion\n            assertEquals(Set.of(), fixVersions(issue));\n        }\n    }\n\n    @Test\n    void testIssueHeadVersion(TestInfo testInfo) throws IOException {\n        headVersionHelper(testInfo, true);\n    }\n    @Test\n    void testIssueHeadVersionFalse(TestInfo testInfo) throws IOException {\n        headVersionHelper(testInfo, false);\n    }\n\n    private void headVersionHelper(TestInfo testInfo, boolean useHeadVersion) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), \"1\");\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n            var baseHash = localRepo.resolve(\"HEAD\").orElseThrow();\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object()\n                    .put(\"fixversions\", JSON.object())\n                    .put(\"headversion\", JSON.of(useHeadVersion));\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\");\n\n            // Update the fix version in a change parallel to the fix and then merge them together\n            localRepo.checkout(baseHash);\n            var jcheckConfFile = repoFolder.resolve(\".jcheck/conf\");\n            var jcheckConfContents = Files.readAllLines(jcheckConfFile).stream()\n                    .map(l -> l.startsWith(\"version=\") ? \"version=2\" : l)\n                    .toList();\n            Files.write(jcheckConfFile, jcheckConfContents);\n            localRepo.add(jcheckConfFile);\n            var newVersionHash = localRepo.commit(\"Update fixversion\", \"testauthor\", \"ta@none.none\");\n            localRepo.checkout(new Branch(\"master\"));\n            localRepo.merge(newVersionHash);\n            var mergeHash = localRepo.commit(\"Merge\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(mergeHash, repo.authenticatedUrl(), \"master\");\n\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in a comment\n            var comments = issue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n\n            // The fixVersion should be 1 or 2 depending useHeadVersion\n            if (useHeadVersion) {\n                assertEquals(Set.of(\"2\"), fixVersions(issue));\n            } else {\n                assertEquals(Set.of(\"1\"), fixVersions(issue));\n            }\n        }\n    }\n\n    @Test\n    void testIssueConfiguredVersionNoCommit(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), null);\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object().put(\"master\", \"2.0\"));\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should not reflected in a comment\n            var comments = issue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n\n            // As well as a fixVersion - but not the one from the repo\n            assertEquals(Set.of(\"2.0\"), fixVersions(issue));\n\n            // And no commit link\n            var links = issue.links();\n            assertEquals(0, links.size());\n        }\n    }\n\n    @Test\n    void testIssueIdempotence(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object());\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Save the state\n            var historyState = localRepo.fetch(repo.authenticatedUrl(), \"history\").orElseThrow();\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in a comment\n            var comments = issue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n\n            // As well as a fixVersion\n            assertEquals(Set.of(\"0.1\"), fixVersions(issue));\n\n            // Wipe the history\n            localRepo.push(historyState, repo.authenticatedUrl(), \"history\", true);\n\n            // Run it again\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // There should be no new comments or fixVersions\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(1, updatedIssue.comments().size());\n            assertEquals(Set.of(\"0.1\"), fixVersions(updatedIssue));\n        }\n    }\n\n    /**\n     * The format used for commit URLs in bug comments and elsewhere was changed\n     * to use the full hash instead of an abbreviated hash. This test verifies\n     * that the idempotence of the IssueNotifier holds true even when\n     * encountering old bug comments containing the old commit URL format.\n     */\n    @Test\n    void testIssueIdempotenceOldUrlFormat(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object());\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Save the state\n            var historyState = localRepo.fetch(repo.authenticatedUrl(), \"history\").orElseThrow();\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n\n            // Add a comment for the fix with the old url hash format\n            var lastCommit = localRepo.commits().stream().findFirst().orElseThrow();\n            issue.addComment(CommitFormatters.toTextBrief(repo, lastCommit, new Branch(\"master\")).replace(editHash.toString(), editHash.abbreviate()));\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Verify that the planted comment is still the only one\n            var comments = issue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            // We expect the abbreviated hash in the planted comment\n            assertTrue(comment.body().contains(editHash.abbreviate()));\n\n            // As well as a fixVersion\n            assertEquals(Set.of(\"0.1\"), fixVersions(issue));\n\n            // Wipe the history\n            localRepo.push(historyState, repo.authenticatedUrl(), \"history\", true);\n\n            // Run it again\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // There should be no new comments or fixVersions\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(1, updatedIssue.comments().size());\n            assertEquals(Set.of(\"0.1\"), fixVersions(updatedIssue));\n        }\n    }\n\n    @Test\n    void testIssuePoolVersion(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), null);\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object().put(\"master\", \"12.0.1\"));\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"12-pool\").add(\"tbd_major\").add(\"unknown\"));\n\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The fixVersion should have been updated\n            assertEquals(Set.of(\"12.0.1\"), fixVersions(issue));\n        }\n    }\n\n    @Test\n    void testIssueBackport(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), null);\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object().put(\".*aster\", \"12.0.2\"));\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"),\n                    Map.of(\"issuetype\", JSON.of(\"Enhancement\"),\n                            SUBCOMPONENT, JSON.of(\"java.io\"),\n                            RESOLVED_IN_BUILD, JSON.of(\"b07\")\n                    ));\n            var level = issue.properties().get(\"security\");\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"13.0.1\"));\n            issue.setProperty(\"priority\", JSON.of(\"1\"));\n            issue.addLabel(\"test\");\n            issue.addLabel(\"temporary\");\n\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The fixVersion should not have been updated\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(Set.of(\"13.0.1\"), fixVersions(updatedIssue));\n            assertEquals(OPEN, updatedIssue.state());\n            assertEquals(List.of(), updatedIssue.assignees());\n\n            // There should be a link\n            var links = updatedIssue.links();\n            assertEquals(1, links.size());\n            var link = links.get(0);\n            var backport = link.issue().orElseThrow();\n\n            // The backport issue should have a correct fixVersion and assignee\n            assertEquals(Set.of(\"12.0.2\"), fixVersions(backport));\n            assertEquals(RESOLVED, backport.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), backport.assignees());\n\n            // Custom properties should also propagate\n            assertEquals(\"1\", backport.properties().get(\"priority\").asString());\n            assertEquals(\"java.io\", backport.properties().get(SUBCOMPONENT).asString());\n            assertFalse(backport.properties().containsKey(RESOLVED_IN_BUILD));\n\n            // Labels should not\n            assertEquals(0, backport.labelNames().size());\n        }\n    }\n\n    @Test\n    void testIssueBackportDefaultSecurity(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), null);\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n            // Initialize other branches\n            var initialHead = localRepo.head();\n            localRepo.push(initialHead, repo.authenticatedUrl(), \"other\");\n            localRepo.push(initialHead, repo.authenticatedUrl(), \"other2\");\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object()\n                    .put(\"fixversions\", JSON.object()\n                            .put(\".*aster\", \"20.0.2\")\n                            .put(\"other\", \"20.0.1\")\n                            .put(\"other2\", \"19.0.2\"))\n                    .put(\"defaultsecurity\", JSON.object()\n                            .put(\"othe.*\", \"100\"));\n\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"),\n                    Map.of(\"issuetype\", JSON.of(\"Enhancement\"),\n                            SUBCOMPONENT, JSON.of(\"java.io\"),\n                            RESOLVED_IN_BUILD, JSON.of(\"b07\")\n                    ));\n            var level = issue.properties().get(\"security\");\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"21\"));\n            issue.setProperty(\"priority\", JSON.of(\"1\"));\n\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            {\n                // The fixVersion should not have been updated\n                var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n                assertEquals(Set.of(\"21\"), fixVersions(updatedIssue));\n                assertEquals(OPEN, updatedIssue.state());\n                assertEquals(List.of(), updatedIssue.assignees());\n\n                // There should be a link\n                var links = updatedIssue.links();\n                assertEquals(1, links.size());\n                var link = links.get(0);\n                var backport = link.issue().orElseThrow();\n\n                // The backport issue should have a correct fixVersion and no security\n                assertEquals(Set.of(\"20.0.2\"), fixVersions(backport));\n                assertNull(backport.properties().get(\"security\"));\n            }\n\n            // Push the fix to other branch\n            localRepo.push(editHash, repo.authenticatedUrl(), \"other\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            {\n                // Find the new backport\n                var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n                var links = updatedIssue.links();\n                assertEquals(2, links.size());\n                var backport = links.get(1).issue().orElseThrow();\n\n                // The backport issue should have a correct fixVersion and security\n                assertEquals(Set.of(\"20.0.1\"), fixVersions(backport));\n                assertEquals(\"100\", backport.properties().get(\"security\").asString());\n            }\n\n            // Set security on the original issue\n            issue.setProperty(\"security\", JSON.of(\"200\"));\n            // Push to another branch\n            localRepo.push(editHash, repo.authenticatedUrl(), \"other2\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            {\n                // Find the new backport\n                var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n                var links = updatedIssue.links();\n                assertEquals(3, links.size());\n                var backport = links.get(2).issue().orElseThrow();\n\n                // The backport issue should have a correct fixVersion and security\n                assertEquals(Set.of(\"19.0.2\"), fixVersions(backport));\n                assertEquals(\"200\", backport.properties().get(\"security\").asString());\n            }\n        }\n    }\n\n    @Test\n    void testIssueOriginalRepo(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var originalRepo = credentials.getHostedRepository(\"original\");\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object())\n                    .put(\"buildname\", \"team\")\n                    .put(\"originalrepository\", \"original\")\n                    .put(\"repoonly\", JSON.of(true));\n            var testBotFactoryBuilder = testBotBuilderFactory(repo, issueProject, storageFolder, jbsNotifierConfig);\n            testBotFactoryBuilder.addHostedRepository(\"original\", originalRepo);\n            var notifyBot = testBotFactoryBuilder.build().create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            // Also create a pull request that should not get processed due to repoonly being set\n            localRepo.push(editHash, repo.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(repo, \"edit\", \"master\", issue.id() + \": Fix that issue\");\n            pr.setBody(\"\\n\\n### Issue\\n * [\" + issue.id() + \"](http://www.test.test/): The issue\");\n            pr.addLabel(\"rfr\");\n            var reviewPr = reviewer.pullRequest(pr.id());\n            reviewPr.addComment(\"This is now ready\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // No PR link should have been added\n            var links = issue.links();\n            assertEquals(0, links.size());\n\n            // The changeset should be reflected in a comment\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n\n            var comments = updatedIssue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n            // Verify that the 'original' repo URL is used in the comment and not the main one\n            assertTrue(comment.body().contains(originalRepo.url().toString()));\n            assertFalse(comment.body().contains(repo.url().toString()));\n\n            // As well as a fixVersion and a resolved in build\n            assertEquals(Set.of(\"0.1\"), fixVersions(updatedIssue));\n            assertEquals(\"team\", updatedIssue.properties().get(RESOLVED_IN_BUILD).asString());\n\n            // The issue should be assigned and resolved\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue.assignees());\n        }\n    }\n\n    @Test\n    void testAltFixVersionsNoMatch(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), null);\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object().put(\"master\", \"jdk-cpu\"));\n            jbsNotifierConfig.put(\"altfixversions\", JSON.object().put(\"master\", JSON.array().add(\"18\")));\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"),\n                    Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"17\"));\n            issue.setState(RESOLVED);\n\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The fixVersion should not have been updated\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(Set.of(\"17\"), fixVersions(updatedIssue));\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(), updatedIssue.assignees());\n\n            // There should be a link\n            var links = updatedIssue.links();\n            assertEquals(1, links.size());\n            var link = links.get(0);\n            var backport = link.issue().orElseThrow();\n\n            // The backport issue should have a correct fixVersion and assignee\n            assertEquals(Set.of(\"jdk-cpu\"), fixVersions(backport));\n            assertEquals(RESOLVED, backport.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), backport.assignees());\n\n        }\n    }\n\n    @Test\n    void testAltFixVersionsMatch(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), null);\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object().put(\"master\", \"jdk-cpu\"));\n            jbsNotifierConfig.put(\"altfixversions\", JSON.object().put(\"master\", JSON.array().add(\"18\")));\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"),\n                    Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"18\"));\n            issue.setState(RESOLVED);\n\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The fixVersion should not have been updated\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(Set.of(\"18\"), fixVersions(updatedIssue));\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue.assignees());\n            // A commit comment should have been added\n            List<Comment> comments = updatedIssue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n            assertTrue(comment.body().contains(repo.url().toString()));\n\n            // There should be no link\n            var links = updatedIssue.links();\n            assertEquals(0, links.size());\n        }\n    }\n\n    @Test\n    void testAltFixVersionsMatchRegex(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), null);\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object().put(\"master\", \"jdk-cpu\"));\n            jbsNotifierConfig.put(\"altfixversions\", JSON.object().put(\"m.*\", JSON.array().add(\"1[78]\")));\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"),\n                    Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"18\"));\n            issue.setState(RESOLVED);\n\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The fixVersion should not have been updated\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(Set.of(\"18\"), fixVersions(updatedIssue));\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue.assignees());\n            // A commit comment should have been added\n            List<Comment> comments = updatedIssue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n            assertTrue(comment.body().contains(repo.url().toString()));\n\n            // There should be no link\n            var links = updatedIssue.links();\n            assertEquals(0, links.size());\n        }\n    }\n\n    @Test\n    void testIssueBackportWithTag(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), null);\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object().put(\".*aster\", \"12.0.2\")).put(\"buildname\", \"team\");\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            var current = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.tag(current, \"jdk-12.0.2+9\", \"First tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"),\n                    Map.of(\"issuetype\", JSON.of(\"Enhancement\"),\n                            SUBCOMPONENT, JSON.of(\"java.io\"),\n                            RESOLVED_IN_BUILD, JSON.of(\"b07\")\n                    ));\n            var level = issue.properties().get(\"security\");\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"13.0.1\"));\n            issue.setProperty(\"priority\", JSON.of(\"1\"));\n            issue.addLabel(\"test\");\n            issue.addLabel(\"temporary\");\n\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n\n            // Tag it\n            localRepo.tag(editHash, \"jdk-12.0.2+110\", \"Second tag\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(new Branch(repo.authenticatedUrl().toString()), \"--tags\", false);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Same RepositoryWorkItem handles both tag and the commit\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The fixVersion should not have been updated\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(Set.of(\"13.0.1\"), fixVersions(updatedIssue));\n            assertEquals(OPEN, updatedIssue.state());\n            assertEquals(List.of(), updatedIssue.assignees());\n\n            // There should be a link\n            var links = updatedIssue.links();\n            assertEquals(1, links.size());\n            var link = links.get(0);\n            var backport = link.issue().orElseThrow();\n\n            // The backport issue should have a correct fixVersion and assignee\n            assertEquals(Set.of(\"12.0.2\"), fixVersions(backport));\n            assertEquals(RESOLVED, backport.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), backport.assignees());\n\n            // Custom properties should also propagate\n            assertEquals(\"1\", backport.properties().get(\"priority\").asString());\n            assertEquals(\"java.io\", backport.properties().get(SUBCOMPONENT).asString());\n\n            // Labels should not\n            assertEquals(0, backport.labelNames().size());\n\n            // Resolved in Build should be updated\n            assertEquals(\"b110\", backport.properties().get(RESOLVED_IN_BUILD).asString());\n        }\n    }\n\n    @Test\n    void testFailedIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var issueProject = credentials.getIssueProject();\n            var reviewIcon = URI.create(\"http://www.example.com/review.png\");\n            var notifyBot = NotifyBot.newBuilder()\n                    .repository(repo)\n                    .storagePath(storageFolder)\n                    .branches(Pattern.compile(\"master\"))\n                    .tagStorageBuilder(tagStorage)\n                    .branchStorageBuilder(branchStorage)\n                    .prStateStorageBuilder(prStateStorage)\n                    .integratorId(repo.forge().currentUser().id())\n                    .build();\n            var updater = IssueNotifier.newBuilder()\n                    .issueProject(issueProject)\n                    .reviewIcon(reviewIcon)\n                    .commitLink(false)\n                    .build();\n            // Register a RepositoryListener to make history initialize on the first run\n            notifyBot.registerRepositoryListener(new NullRepositoryListener());\n            updater.attachTo(notifyBot);\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Save the state\n            var historyState = localRepo.fetch(repo.authenticatedUrl(), \"history\").orElseThrow();\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            var pr = credentials.createPullRequest(repo, \"master\", \"master\", issue.id() + \": Fix that issue\");\n            pr.setBody(\"\\n\\n### Issue\\n * \" + \"⚠️ Temporary failure when trying to retrieve information on issue `\" + issue.id() + \"`.\" + TEMPORARY_ISSUE_FAILURE_MARKER);\n            pr.addLabel(\"rfr\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // There should not be any links in the issue\n            var links = issue.links();\n            assertEquals(0, links.size());\n\n            //Resolve the temporary issue failure\n            pr.setBody(\"\\n\\n### Issue\\n * [\" + issue.id() + \"](http://www.test.test/): The issue\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n            links = issue.links();\n            assertEquals(1, links.size());\n            var link = links.get(0);\n            assertEquals(reviewIcon, link.iconUrl().orElseThrow());\n            assertEquals(\"Review(master)\", link.title().orElseThrow());\n            assertEquals(pr.webUrl(), link.uri().orElseThrow());\n\n            // Wipe the history\n            localRepo.push(historyState, repo.authenticatedUrl(), \"history\", true);\n\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // There should be no new links\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(1, updatedIssue.links().size());\n        }\n    }\n\n    @Test\n    void testAvoidForwardports(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), null);\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object().put(\".*aster\", \"22\")).put(\"avoidforwardports\", true);\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"),\n                    Map.of(\"issuetype\", JSON.of(\"Enhancement\"),\n                            SUBCOMPONENT, JSON.of(\"java.io\"),\n                            RESOLVED_IN_BUILD, JSON.of(\"b07\")\n                    ));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"21\"));\n            issue.setProperty(\"priority\", JSON.of(\"1\"));\n            issue.addLabel(\"test\");\n            issue.addLabel(\"temporary\");\n\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The fixVersion should have been set to 22\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(Set.of(\"22\"), fixVersions(updatedIssue));\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue.assignees());\n\n            // There should be a link\n            var links = updatedIssue.links();\n            assertEquals(1, links.size());\n            var link = links.get(0);\n            var backport = link.issue().orElseThrow();\n\n            // The backport issue should have the issue's fixVersions\n            assertEquals(Set.of(\"21\"), fixVersions(backport));\n            assertEquals(OPEN, backport.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), backport.assignees());\n\n            // Custom properties should also propagate\n            assertEquals(\"1\", backport.properties().get(\"priority\").asString());\n            assertEquals(\"java.io\", backport.properties().get(SUBCOMPONENT).asString());\n            assertFalse(backport.properties().containsKey(RESOLVED_IN_BUILD));\n\n            // Labels should not\n            assertEquals(0, backport.labelNames().size());\n        }\n    }\n\n    @Test\n    void testAvoidForwardportsShouldCreateBackport(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), null);\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object().put(\".*aster\", \"21\")).put(\"avoidforwardports\", true);\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"),\n                    Map.of(\"issuetype\", JSON.of(\"Enhancement\"),\n                            SUBCOMPONENT, JSON.of(\"java.io\"),\n                            RESOLVED_IN_BUILD, JSON.of(\"b07\")\n                    ));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"22\"));\n            issue.setProperty(\"priority\", JSON.of(\"1\"));\n            issue.addLabel(\"test\");\n            issue.addLabel(\"temporary\");\n\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The fixVersion should not have been updated\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(Set.of(\"22\"), fixVersions(updatedIssue));\n            assertEquals(OPEN, updatedIssue.state());\n            assertEquals(List.of(), updatedIssue.assignees());\n\n            // There should be a link\n            var links = updatedIssue.links();\n            assertEquals(1, links.size());\n            var link = links.get(0);\n            var backport = link.issue().orElseThrow();\n\n            // The backport issue should have the repository's fixVersions\n            assertEquals(Set.of(\"21\"), fixVersions(backport));\n            assertEquals(RESOLVED, backport.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), backport.assignees());\n\n            // Custom properties should also propagate\n            assertEquals(\"1\", backport.properties().get(\"priority\").asString());\n            assertEquals(\"java.io\", backport.properties().get(SUBCOMPONENT).asString());\n            assertFalse(backport.properties().containsKey(RESOLVED_IN_BUILD));\n\n            // Labels should not\n            assertEquals(0, backport.labelNames().size());\n        }\n    }\n\n    @Test\n    void testAvoidForwardportsShouldUseExistingForwardport(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), null);\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object().put(\".*aster\", \"22\")).put(\"avoidforwardports\", true);\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"),\n                    Map.of(\"issuetype\", JSON.of(\"Enhancement\"),\n                            SUBCOMPONENT, JSON.of(\"java.io\"),\n                            RESOLVED_IN_BUILD, JSON.of(\"b07\")\n                    ));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"21\"));\n            issue.setProperty(\"priority\", JSON.of(\"1\"));\n            issue.addLabel(\"test\");\n            issue.addLabel(\"temporary\");\n\n            // Create an explicit \"forwardport\"\n            var forwardPort = issueProject.createIssue(\"This is a forwardport\", List.of(\"Indeed\"),\n                    Map.of(\"issuetype\", JSON.of(\"Backport\")));\n            forwardPort.setProperty(\"fixVersions\", JSON.array().add(\"22\"));\n\n            issue.addLink(Link.create(forwardPort, \"backported by\").build());\n            forwardPort.addLink(Link.create(issue, \"backport of\").build());\n\n            // Commit a fix for the issue\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The fixVersion should not have been updated\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(Set.of(\"21\"), fixVersions(updatedIssue));\n            assertEquals(OPEN, updatedIssue.state());\n            assertEquals(List.of(), updatedIssue.assignees());\n\n            // There should still be just a single link\n            var links = updatedIssue.links();\n            assertEquals(1, links.size());\n            var link = links.get(0);\n            var backport = link.issue().orElseThrow();\n\n            // The forwardport issue should have the repository's fixVersions\n            assertEquals(Set.of(\"22\"), fixVersions(backport));\n            assertEquals(RESOLVED, backport.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), backport.assignees());\n\n            // No properties should have propagated\n            assertFalse(backport.properties().containsKey(SUBCOMPONENT));\n            assertFalse(backport.properties().containsKey(RESOLVED_IN_BUILD));\n\n            // Not Labels should have propagated\n            assertEquals(0, backport.labelNames().size());\n        }\n    }\n\n    @Test\n    void testAvoidForwardportsShouldUseExistingBackport(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), null);\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object().put(\".*aster\", \"21\")).put(\"avoidforwardports\", true);\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"),\n                    Map.of(\"issuetype\", JSON.of(\"Enhancement\"),\n                            SUBCOMPONENT, JSON.of(\"java.io\"),\n                            RESOLVED_IN_BUILD, JSON.of(\"b07\")\n                    ));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"22\"));\n            issue.setProperty(\"priority\", JSON.of(\"1\"));\n            issue.addLabel(\"test\");\n            issue.addLabel(\"temporary\");\n\n            // Create an explicit backport\n            var backport = issueProject.createIssue(\"This is a backport\", List.of(\"Indeed\"),\n                    Map.of(\"issuetype\", JSON.of(\"Backport\")));\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"21\"));\n\n            issue.addLink(Link.create(backport, \"backported by\").build());\n            backport.addLink(Link.create(issue, \"backport of\").build());\n\n            // Commit a fix for the issue\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The fixVersion should not have been updated\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(Set.of(\"22\"), fixVersions(updatedIssue));\n            assertEquals(OPEN, updatedIssue.state());\n            assertEquals(List.of(), updatedIssue.assignees());\n\n            // There should still be just a single link\n            var links = updatedIssue.links();\n            assertEquals(1, links.size());\n            var link = links.get(0);\n            var updatedBackport = link.issue().orElseThrow();\n\n            // The backport issue should have the repository's fixVersions\n            assertEquals(Set.of(\"21\"), fixVersions(updatedBackport));\n            assertEquals(RESOLVED, updatedBackport.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedBackport.assignees());\n\n            // No properties should have propagated\n            assertFalse(updatedBackport.properties().containsKey(SUBCOMPONENT));\n            assertFalse(updatedBackport.properties().containsKey(RESOLVED_IN_BUILD));\n\n            // Not Labels should have propagated\n            assertEquals(0, updatedBackport.labelNames().size());\n        }\n    }\n\n    @Test\n    void testAvoidForwardportsOnResolvedIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), null);\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var jbsNotifierConfig = JSON.object().put(\"fixversions\", JSON.object().put(\".*aster\", \"22\")).put(\"avoidforwardports\", true);\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"),\n                    Map.of(\"issuetype\", JSON.of(\"Enhancement\"),\n                            SUBCOMPONENT, JSON.of(\"java.io\"),\n                            RESOLVED_IN_BUILD, JSON.of(\"b07\")\n                    ));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"21\"));\n            issue.setProperty(\"priority\", JSON.of(\"1\"));\n            issue.addLabel(\"test\");\n            issue.addLabel(\"temporary\");\n            issue.setState(RESOLVED);\n            issue.setProperty(\"resolution\", JSON.object().put(\"name\", JSON.of(\"Delivered\")));\n            issue.setAssignees(List.of(issueProject.issueTracker().currentUser()));\n\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The fixVersion of the main issue should still be 21\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(Set.of(\"21\"), fixVersions(updatedIssue));\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue.assignees());\n            // The resolution of the issue should still be \"Delivered\"\n            assertEquals(\"Delivered\", issue.properties().get(\"resolution\").get(\"name\").asString());\n\n            // There should be a link\n            var links = updatedIssue.links();\n            assertEquals(1, links.size());\n            var link = links.get(0);\n            var backport = link.issue().orElseThrow();\n\n            // The backport issue should target to release 22\n            assertEquals(Set.of(\"22\"), fixVersions(backport));\n            assertEquals(RESOLVED, backport.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), backport.assignees());\n\n            // Custom properties should also propagate\n            assertEquals(\"1\", backport.properties().get(\"priority\").asString());\n            assertEquals(\"java.io\", backport.properties().get(SUBCOMPONENT).asString());\n            assertFalse(backport.properties().containsKey(RESOLVED_IN_BUILD));\n\n            // Labels should not\n            assertEquals(0, backport.labelNames().size());\n        }\n    }\n\n    @Test\n    void testTargetBranchUpdate(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var issueProject = credentials.getIssueProject();\n            var reviewIcon = URI.create(\"http://www.example.com/review.png\");\n            var notifyBot = NotifyBot.newBuilder()\n                    .repository(repo)\n                    .storagePath(storageFolder)\n                    .branches(Pattern.compile(\"master\"))\n                    .tagStorageBuilder(tagStorage)\n                    .branchStorageBuilder(branchStorage)\n                    .prStateStorageBuilder(prStateStorage)\n                    .integratorId(repo.forge().currentUser().id())\n                    .build();\n            var updater = IssueNotifier.newBuilder()\n                    .issueProject(issueProject)\n                    .reviewLink(true)\n                    .reviewIcon(reviewIcon)\n                    .build();\n            // Register a RepositoryListener to make history initialize on the first run\n            notifyBot.registerRepositoryListener(new NullRepositoryListener());\n            updater.attachTo(notifyBot);\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Save the state\n            var historyState = localRepo.fetch(repo.authenticatedUrl(), \"history\").orElseThrow();\n\n            // Create an issue and commit a fix\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            var pr = credentials.createPullRequest(repo, \"master\", \"master\", issue.id() + \": Fix that issue\");\n            pr.addLabel(\"rfr\");\n            pr.setBody(\"\\n\\n### Issue\\n * [\" + issue.id() + \"](http://www.test.test/): The issue\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // There should be a review link\n            var links = issue.links();\n            assertEquals(1, links.size());\n            var link = links.get(0);\n            assertEquals(reviewIcon, link.iconUrl().orElseThrow());\n            assertEquals(\"Review(master)\", link.title().orElseThrow());\n            assertEquals(pr.webUrl(), link.uri().orElseThrow());\n            assertTrue(issue.comments().getLast().body().contains(\"Branch: master\"));\n\n            // Retarget the pr\n            pr.setTargetRef(\"jdk23\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The review link should be updated\n            links = issue.links();\n            assertEquals(1, links.size());\n            link = links.get(0);\n            assertEquals(reviewIcon, link.iconUrl().orElseThrow());\n            assertEquals(\"Review(jdk23)\", link.title().orElseThrow());\n            assertEquals(pr.webUrl(), link.uri().orElseThrow());\n            assertTrue(issue.comments().getLast().body().contains(\"Branch: jdk23\"));\n        }\n    }\n\n    @Test\n    void testIssueMultipleFixVersions(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n                var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType(), Path.of(\"file.txt\"), Set.of(), null);\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var jbsNotifierConfig = JSON.object()\n                    .put(\"fixversions\", JSON.object()\n                            .put(\"other\", \"branch-foo1\")\n                            .put(\"other2\", \"branch-foo2\"))\n                    .put(\"multifixversions\", true);\n            var notifyBot = testBotBuilder(repo, issueProject, storageFolder, jbsNotifierConfig).create(\"notify\", JSON.object());\n\n            // Initialize state for all active branches\n            localRepo.push(\"master:other\", repo.authenticatedUrl());\n            localRepo.push(\"master:other2\", repo.authenticatedUrl());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create an issue and commit a fix\n            var authorEmailAddress = issueProject.issueTracker().currentUser().username() + \"@openjdk.org\";\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\", \"Duke\", authorEmailAddress);\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The changeset should be reflected in a comment\n            var updatedIssue = issueProject.issue(issue.id()).orElseThrow();\n\n            var comments = updatedIssue.comments();\n            assertEquals(1, comments.size());\n            var comment = comments.get(0);\n            assertTrue(comment.body().contains(editHash.toString()));\n\n            // As well as a fixVersion and a resolved in build\n            assertEquals(Set.of(), fixVersions(updatedIssue));\n\n            // The issue should be assigned and resolved\n            assertEquals(RESOLVED, updatedIssue.state());\n            assertEquals(List.of(issueProject.issueTracker().currentUser()), updatedIssue.assignees());\n\n            // Push to a branch with a fixVersion config\n            localRepo.push(editHash, repo.authenticatedUrl(), \"other2\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            assertEquals(Set.of(\"branch-foo2\"), fixVersions(updatedIssue));\n\n            // Push to the other branch with a fixVersion config\n            localRepo.push(editHash, repo.authenticatedUrl(), \"other\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            assertEquals(Set.of(\"branch-foo2\", \"branch-foo1\"), fixVersions(updatedIssue));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/test/java/org/openjdk/skara/bots/notify/json/JsonNotifierTests.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.json;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.util.List;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.openjdk.skara.bots.notify.TestUtils.*;\n\npublic class JsonNotifierTests {\n    private List<Path> findJsonFiles(Path folder, String partialName) throws IOException {\n        try (var paths = Files.walk(folder)) {\n            return paths.filter(path -> path.toString().endsWith(\".json\"))\n                        .filter(path -> path.toString().contains(partialName))\n                        .collect(Collectors.toList());\n        }\n    }\n\n    @Test\n    void testJsonNotifierBranch(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var localRepoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var jsonFolder = tempFolder.path().resolve(\"json\");\n            Files.createDirectory(jsonFolder);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n\n            var updater = new JsonNotifier(jsonFolder, \"12\", \"team\");\n            updater.attachTo(notifyBot);\n\n            TestBotRunner.runPeriodicItems(notifyBot);\n            assertEquals(1, findJsonFiles(jsonFolder, \"\").size());\n\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"One more line\", \"12345678: Fixes\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n            var jsonFiles = findJsonFiles(jsonFolder, \"\");\n            assertEquals(2, jsonFiles.size());\n            var jsonData = Files.readString(jsonFiles.get(0));\n            if (JSON.parse(jsonData).asArray().size() != 1) {\n                jsonData = Files.readString(jsonFiles.get(1));\n            }\n            var json = JSON.parse(jsonData);\n            assertEquals(1, json.asArray().size());\n            assertEquals(repo.webUrl(editHash).toString(), json.asArray().get(0).get(\"url\").asString());\n            assertEquals(List.of(\"12345678\"), json.asArray().get(0).get(\"issue\").asArray().stream()\n                                                  .map(JSONValue::asString)\n                                                  .collect(Collectors.toList()));\n        }\n    }\n\n    @Test\n    void testJsonNotifierTag(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var localRepoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.tag(masterHash, \"jdk-12+1\", \"Added tag 1\", \"Duke\", \"duke@openjdk.org\");\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var jsonFolder = tempFolder.path().resolve(\"json\");\n            Files.createDirectory(jsonFolder);\n            var storageFolder =tempFolder.path().resolve(\"storage\");\n\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n\n            var updater = new JsonNotifier(jsonFolder, \"12\", \"team\");\n            updater.attachTo(notifyBot);\n\n            TestBotRunner.runPeriodicItems(notifyBot);\n            assertEquals(1, findJsonFiles(jsonFolder, \"\").size());\n\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\");\n            localRepo.fetch(repo.authenticatedUrl(), \"history:history\").orElseThrow();\n            localRepo.tag(editHash, \"jdk-12+2\", \"Added tag 2\", \"Duke\", \"duke@openjdk.org\");\n            var editHash2 = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"34567890: Even more fixes\");\n            localRepo.tag(editHash2, \"jdk-12+4\", \"Added tag 3\", \"Duke\", \"duke@openjdk.org\");\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            TestBotRunner.runPeriodicItems(notifyBot);\n            var jsonFiles = findJsonFiles(jsonFolder, \"\");\n            assertEquals(4, jsonFiles.size());\n\n            for (var file : jsonFiles) {\n                var jsonData = Files.readString(file);\n                var json = JSON.parse(jsonData);\n\n                if (json.asArray().get(0).contains(\"date\")) {\n                    if (json.asArray().get(0).get(\"issue\").asArray().size() == 0) {\n                        continue;\n                    }\n                    assertEquals(2, json.asArray().size());\n                    assertEquals(List.of(\"23456789\"), json.asArray().get(0).get(\"issue\").asArray().stream()\n                                                          .map(JSONValue::asString)\n                                                          .collect(Collectors.toList()));\n                    assertEquals(repo.webUrl(editHash).toString(), json.asArray().get(0).get(\"url\").asString());\n                    assertEquals(\"team\", json.asArray().get(0).get(\"build\").asString());\n                    assertEquals(List.of(\"34567890\"), json.asArray().get(1).get(\"issue\").asArray().stream()\n                                                          .map(JSONValue::asString)\n                                                          .collect(Collectors.toList()));\n                    assertEquals(repo.webUrl(editHash2).toString(), json.asArray().get(1).get(\"url\").asString());\n                    assertEquals(\"team\", json.asArray().get(1).get(\"build\").asString());\n                } else {\n                    assertEquals(1, json.asArray().size());\n                    if (json.asArray().get(0).get(\"build\").asString().equals(\"b02\")) {\n                        assertEquals(List.of(\"23456789\"), json.asArray().get(0).get(\"issue\").asArray().stream()\n                                                              .map(JSONValue::asString)\n                                                              .collect(Collectors.toList()));\n                    } else {\n                        assertEquals(\"b04\", json.asArray().get(0).get(\"build\").asString());\n                        assertEquals(List.of(\"34567890\"), json.asArray().get(0).get(\"issue\").asArray().stream()\n                                                              .map(JSONValue::asString)\n                                                              .collect(Collectors.toList()));\n                    }\n                }\n            }\n        }\n    }}\n"
  },
  {
    "path": "bots/notify/src/test/java/org/openjdk/skara/bots/notify/mailinglist/MailingListNotifierTests.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.mailinglist;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.email.*;\nimport org.openjdk.skara.bots.notify.*;\nimport org.openjdk.skara.mailinglist.MailingListServerFactory;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.notify.TestUtils.*;\n\npublic class MailingListNotifierTests {\n    @Test\n    void testMailingList(TestInfo testInfo) throws IOException {\n        try (var listServer = TestMailmanServer.createV3();\n             var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var listAddress = listServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(), listServer.getSMTP(), Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var sender = EmailAddress.from(\"duke\", \"duke@duke.duke\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n            var updater = MailingListNotifier.newBuilder()\n                                             .server(mailmanServer)\n                                             .recipient(listAddress)\n                                             .sender(sender)\n                                             .reportNewTags(false)\n                                             .reportNewBranches(false)\n                                             .reportNewBuilds(false)\n                                             .headers(Map.of(\"extra1\", \"value1\", \"extra2\", \"value2\"))\n                                             .allowedAuthorDomains(Pattern.compile(\"none\"))\n                                             .build();\n            updater.attachTo(notifyBot);\n\n            // One mail should be sent on first commit\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            conversations.sort(Comparator.comparing(conversation -> conversation.first().subject()));\n            // get the latest email\n            var email = conversations.get(1).first();\n            assertEquals(listAddress, email.sender());\n            assertEquals(sender, email.author());\n            assertEquals(email.recipients(), List.of(listAddress));\n            assertTrue(email.subject().contains(\": 23456789: More fixes\"));\n            assertFalse(email.subject().contains(\"master\"));\n            assertTrue(email.body().contains(\"Changeset: \" + editHash.abbreviate()));\n            assertTrue(email.body().contains(\"23456789: More fixes\"));\n            assertFalse(email.body().contains(\"Committer\"));\n            assertFalse(email.body().contains(masterHash.abbreviate()));\n            assertTrue(email.hasHeader(\"extra1\"));\n            assertEquals(\"value1\", email.headerValue(\"extra1\"));\n            assertTrue(email.hasHeader(\"extra2\"));\n            assertEquals(\"value2\", email.headerValue(\"extra2\"));\n            assertTrue(email.hasHeader(\"X-Git-URL\"));\n            assertEquals(repo.webUrl().toString(), email.headerValue(\"X-Git-URL\"));\n            assertTrue(email.hasHeader(\"X-Git-Changeset\"));\n            assertEquals(editHash.hex(), email.headerValue(\"X-Git-Changeset\"));\n        }\n    }\n\n    @Test\n    void testMailingListMultiple(TestInfo testInfo) throws IOException {\n        try (var listServer = TestMailmanServer.createV3();\n             var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var listAddress = listServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(), listServer.getSMTP(), Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var sender = EmailAddress.from(\"duke\", \"duke@duke.duke\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n            var updater = MailingListNotifier.newBuilder()\n                                             .server(mailmanServer)\n                                             .recipient(listAddress)\n                                             .sender(sender)\n                                             .reportNewTags(false)\n                                             .reportNewBranches(false)\n                                             .reportNewBuilds(false)\n                                             .build();\n            updater.attachTo(notifyBot);\n\n            // One mail should be sent on first commit\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var editHash1 = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\",\n                    \"first_author\", \"first@author.example.com\");\n            localRepo.push(editHash1, repo.authenticatedUrl(), \"master\");\n            var editHash2 = CheckableRepository.appendAndCommit(localRepo, \"Yet another line\", \"3456789A: Even more fixes\",\n                    \"another_author\", \"another@author.example.com\");\n            localRepo.push(editHash2, repo.authenticatedUrl(), \"master\");\n\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            conversations.sort(Comparator.comparing(conversation -> conversation.first().subject()));\n            // get the latest email\n            var email = conversations.get(0).first();\n            if (email.body().contains(\"Initial commit\")) {\n                email = conversations.get(1).first();\n            }\n            assertEquals(listAddress, email.sender());\n            assertEquals(EmailAddress.from(\"another_author\", \"another@author.example.com\"), email.author());\n            assertEquals(email.recipients(), List.of(listAddress));\n            assertTrue(email.subject().contains(\": 2 new changesets\"));\n            assertFalse(email.subject().contains(\"master\"));\n            assertTrue(email.body().contains(\"Changeset: \" + editHash1.abbreviate()));\n            assertTrue(email.body().contains(\"23456789: More fixes\"));\n            assertTrue(email.body().contains(\"Changeset: \" + editHash2.abbreviate()));\n            assertTrue(email.body().contains(\"3456789A: Even more fixes\"));\n            assertFalse(email.body().contains(masterHash.abbreviate()));\n            assertTrue(email.hasHeader(\"X-Git-URL\"));\n            assertEquals(repo.webUrl().toString(), email.headerValue(\"X-Git-URL\"));\n            assertTrue(email.hasHeader(\"X-Git-Changeset\"));\n            assertEquals(editHash1.hex(), email.headerValue(\"X-Git-Changeset\"));\n        }\n    }\n\n    @Test\n    void testMailingListMerge(TestInfo testInfo) throws IOException {\n        try (var listServer = TestMailmanServer.createV3();\n             var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var listAddress = listServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(), listServer.getSMTP(), Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var sender = EmailAddress.from(\"duke\", \"duke@duke.duke\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n            var updater = MailingListNotifier.newBuilder()\n                                             .server(mailmanServer)\n                                             .recipient(listAddress)\n                                             .sender(sender)\n                                             .reportNewTags(false)\n                                             .reportNewBranches(false)\n                                             .reportNewBuilds(false)\n                                             .build();\n            updater.attachTo(notifyBot);\n\n            // One mail should be sent on first commit\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var editHash1 = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\",\n                                                                \"first_author\", \"first@author.example.com\");\n            localRepo.push(editHash1, repo.authenticatedUrl(), \"master\");\n            var editHash2 = CheckableRepository.appendAndCommit(localRepo, \"Yet another line\", \"3456789A: Even more fixes\",\n                                                                \"another_author\", \"another@author.example.com\");\n            localRepo.checkout(editHash1, true);\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Something else\");\n            localRepo.add(unrelated);\n            localRepo.commit(\"Unrelated\", \"unrelated_author\", \"unrelated@author.example.com\");\n            localRepo.merge(editHash2);\n            var mergeHash = localRepo.commit(\"Merge\", \"merge_author\", \"merge@author.example.com\");\n            localRepo.push(mergeHash, repo.authenticatedUrl(), \"master\");\n\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            conversations.sort(Comparator.comparing(conversation -> conversation.first().subject()));\n            // get the latest email\n            var email = conversations.get(1).first();\n            assertEquals(listAddress, email.sender());\n            assertEquals(EmailAddress.from(\"merge_author\", \"merge@author.example.com\"), email.author());\n            assertEquals(email.recipients(), List.of(listAddress));\n            assertTrue(email.subject().contains(\": 4 new changesets\"));\n            assertFalse(email.subject().contains(\"master\"));\n            assertTrue(email.body().contains(\"Changeset: \" + editHash1.abbreviate()));\n            assertTrue(email.body().contains(\"23456789: More fixes\"));\n            assertTrue(email.body().contains(\"Changeset: \" + editHash2.abbreviate()));\n            assertTrue(email.body().contains(\"3456789A: Even more fixes\"));\n            assertFalse(email.body().contains(masterHash.abbreviate()));\n            assertTrue(email.hasHeader(\"X-Git-URL\"));\n            assertEquals(repo.webUrl().toString(), email.headerValue(\"X-Git-URL\"));\n            assertTrue(email.hasHeader(\"X-Git-Changeset\"));\n            assertEquals(editHash1.hex(), email.headerValue(\"X-Git-Changeset\"));\n        }\n    }\n\n    @Test\n    void testMailingListSponsored(TestInfo testInfo) throws IOException {\n        try (var listServer = TestMailmanServer.createV3();\n             var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var listAddress = listServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(), listServer.getSMTP(), Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var sender = EmailAddress.from(\"duke\", \"duke@duke.duke\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n            var updater = MailingListNotifier.newBuilder()\n                                             .server(mailmanServer)\n                                             .recipient(listAddress)\n                                             .sender(sender)\n                                             .reportNewTags(false)\n                                             .reportNewBranches(false)\n                                             .reportNewBuilds(false)\n                                             .build();\n            updater.attachTo(notifyBot);\n\n            // One mail should be sent on first commit\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\",\n                    \"author\", \"author@test.test\",\n                    \"committer\", \"committer@test.test\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            conversations.sort(Comparator.comparing(conversation -> conversation.first().subject()));\n            // get the latest email\n            var email = conversations.get(1).first();\n            assertEquals(listAddress, email.sender());\n            assertEquals(EmailAddress.from(\"committer\", \"committer@test.test\"), email.author());\n            assertEquals(email.recipients(), List.of(listAddress));\n            assertTrue(email.body().contains(\"Changeset: \" + editHash.abbreviate()));\n            assertTrue(email.body().contains(\"23456789: More fixes\"));\n            assertTrue(email.body().contains(\"Author:    author <author@test.test>\"));\n            assertTrue(email.body().contains(\"Committer: committer <committer@test.test>\"));\n            assertFalse(email.body().contains(masterHash.abbreviate()));\n        }\n    }\n\n    @Test\n    void testMailingListMultipleBranches(TestInfo testInfo) throws IOException {\n        try (var listServer = TestMailmanServer.createV3();\n             var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            credentials.commitLock(localRepo);\n            var branch = localRepo.branch(masterHash, \"another\");\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var listAddress = listServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(), listServer.getSMTP(), Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var sender = EmailAddress.from(\"duke\", \"duke@duke.duke\");\n            var author = EmailAddress.from(\"author\", \"author@duke.duke\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master|another\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n            var updater = MailingListNotifier.newBuilder()\n                                             .server(mailmanServer)\n                                             .recipient(listAddress)\n                                             .sender(sender)\n                                             .author(author)\n                                             .includeBranch(true)\n                                             .reportNewTags(false)\n                                             .reportNewBranches(false)\n                                             .reportNewBuilds(false)\n                                             .build();\n            updater.attachTo(notifyBot);\n\n            // Two mails should be sent on first commit\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n            listServer.processIncoming();\n\n            var editHash1 = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\");\n            localRepo.push(editHash1, repo.authenticatedUrl(), \"master\");\n            var editHash2 = CheckableRepository.appendAndCommit(localRepo, \"Yet another line\", \"3456789A: Even more fixes\");\n            localRepo.push(editHash2, repo.authenticatedUrl(), \"master\");\n\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            conversations.sort(Comparator.comparing(conversation -> conversation.first().subject()));\n            // get the latest email\n            var email = conversations.get(1).first();\n            if (email.body().contains(\"Initial commit\")) {\n                email = conversations.get(2).first();\n            }\n            assertEquals(listAddress, email.sender());\n            assertEquals(author, email.author());\n            assertEquals(email.recipients(), List.of(listAddress));\n            assertFalse(email.subject().contains(\"another\"));\n            assertTrue(email.subject().contains(\": master: 2 new changesets\"));\n            assertTrue(email.body().contains(\"Changeset: \" + editHash1.abbreviate()));\n            assertTrue(email.body().contains(\"23456789: More fixes\"));\n            assertTrue(email.body().contains(\"Changeset: \" + editHash2.abbreviate()));\n            assertTrue(email.body().contains(\"3456789A: Even more fixes\"));\n            assertFalse(email.body().contains(masterHash.abbreviate()));\n            assertFalse(email.body().contains(\"456789AB: Yet more fixes\"));\n            assertTrue(email.hasHeader(\"X-Git-URL\"));\n            assertEquals(repo.webUrl().toString(), email.headerValue(\"X-Git-URL\"));\n            assertTrue(email.hasHeader(\"X-Git-Changeset\"));\n            assertEquals(editHash1.hex(), email.headerValue(\"X-Git-Changeset\"));\n\n            localRepo.checkout(branch, true);\n            var editHash3 = CheckableRepository.appendAndCommit(localRepo, \"Another branch\", \"456789AB: Yet more fixes\");\n            localRepo.push(editHash3, repo.authenticatedUrl(), \"another\");\n\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            conversations = mailmanList.conversations(Duration.ofDays(1));\n            conversations.sort(Comparator.comparing(conversation -> conversation.first().subject()));\n            email = conversations.get(0).first();\n            assertEquals(author, email.author());\n            assertEquals(listAddress, email.sender());\n            assertEquals(email.recipients(), List.of(listAddress));\n            assertTrue(email.subject().contains(\": another: 456789AB: Yet more fixes\"));\n            assertFalse(email.subject().contains(\"master\"));\n            assertTrue(email.body().contains(\"Changeset: \" + editHash3.abbreviate()));\n            assertTrue(email.body().contains(\"456789AB: Yet more fixes\"));\n            assertFalse(email.body().contains(\"Changeset: \" + editHash2.abbreviate()));\n            assertFalse(email.body().contains(\"3456789A: Even more fixes\"));\n            assertTrue(email.hasHeader(\"X-Git-URL\"));\n            assertEquals(repo.webUrl().toString(), email.headerValue(\"X-Git-URL\"));\n            assertTrue(email.hasHeader(\"X-Git-Changeset\"));\n            assertEquals(editHash3.hex(), email.headerValue(\"X-Git-Changeset\"));\n        }\n    }\n\n    @Test\n    void testMailingListPROnlyMultipleBranches(TestInfo testInfo) throws IOException {\n        try (var listServer = TestMailmanServer.createV3();\n             var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var listAddress = listServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(), listServer.getSMTP(), Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var sender = EmailAddress.from(\"duke\", \"duke@duke.duke\");\n            var author = EmailAddress.from(\"author\", \"author@duke.duke\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master|other\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n            var updater = MailingListNotifier.newBuilder()\n                                             .server(mailmanServer)\n                                             .recipient(listAddress)\n                                             .sender(sender)\n                                             .author(author)\n                                             .reportNewTags(false)\n                                             .reportNewBranches(false)\n                                             .reportNewBuilds(false)\n                                             .includeBranch(true)\n                                             .mode(MailingListNotifier.Mode.PR)\n                                             .build();\n            updater.attachTo(notifyBot);\n\n            // Populate our known branches\n            localRepo.push(masterHash, repo.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, repo.authenticatedUrl(), \"other\", true);\n\n            // Two mails should be sent on first commit\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n            listServer.processIncoming();\n\n            localRepo.checkout(masterHash, true);\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"edit\");\n            var pr = credentials.createPullRequest(repo, \"master\", \"edit\", \"RFR: My PR\");\n\n            // PR hasn't been integrated yet, so there should be no mail\n            TestBotRunner.runPeriodicItems(notifyBot);\n            assertThrows(RuntimeException.class, () -> listServer.processIncoming(Duration.ofMillis(1)));\n\n            // Simulate an RFR email\n            var rfr = Email.create(sender, \"RFR: My PR\", \"PR: \" + pr.webUrl().toString())\n                           .recipient(listAddress)\n                           .build();\n            mailmanServer.post(rfr);\n            listServer.processIncoming();\n\n            // And an integration (but it hasn't reached master just yet)\n            pr.addComment(\"Pushed as commit \" + editHash.hex() + \".\");\n\n            // Now push the same commit to another branch\n            localRepo.push(editHash, repo.authenticatedUrl(), \"other\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            // This one should generate a plain integration mail\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(4, conversations.size());\n            conversations.sort(Comparator.comparing(conversation -> conversation.first().subject()));\n            var secondEmail = conversations.get(2).first();\n            assertEquals(\"git: test: other: 23456789: More fixes\", secondEmail.subject());\n        }\n    }\n\n    @Test\n    void testMailingListPR(TestInfo testInfo) throws IOException {\n        try (var listServer = TestMailmanServer.createV3();\n             var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var listAddress = listServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(), listServer.getSMTP(), Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var sender = EmailAddress.from(\"duke\", \"duke@duke.duke\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n            var updater = MailingListNotifier.newBuilder()\n                                             .server(mailmanServer)\n                                             .recipient(listAddress)\n                                             .sender(sender)\n                                             .reportNewTags(false)\n                                             .reportNewBranches(false)\n                                             .reportNewBuilds(false)\n                                             .mode(MailingListNotifier.Mode.PR)\n                                             .build();\n            updater.attachTo(notifyBot);\n\n            // One mail should be sent on first commit\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"edit\");\n            var pr = credentials.createPullRequest(repo, \"master\", \"edit\", \"RFR: My PR\");\n\n            // Create a potentially conflicting one\n            var otherHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\");\n            localRepo.push(otherHash, repo.authenticatedUrl(), \"other\");\n            var otherPr = credentials.createPullRequest(repo, \"master\", \"other\", \"RFR: My other PR\");\n\n            // PR hasn't been integrated yet, so there should be no mail\n            TestBotRunner.runPeriodicItems(notifyBot);\n            assertThrows(RuntimeException.class, () -> listServer.processIncoming(Duration.ofMillis(1)));\n\n            // Simulate an RFR email\n            var rfr = Email.create(\"[repo/branch] RFR: My PR\", \"PR:\\n\" + pr.webUrl().toString())\n                           .author(EmailAddress.from(\"duke\", \"duke@duke.duke\"))\n                           .recipient(listAddress)\n                           .build();\n            mailmanServer.post(rfr);\n            listServer.processIncoming();\n\n            // And an integration\n            pr.addComment(\"Pushed as commit \" + editHash.hex() + \".\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n\n            // Push the other one without a matching PR\n            localRepo.push(otherHash, repo.authenticatedUrl(), \"master\");\n\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            conversations.sort(Comparator.comparing(conversation -> conversation.first().subject()));\n            assertEquals(3, conversations.size());\n\n            var prConversation = conversations.get(0);\n            var pushConversation = conversations.get(2);\n            assertEquals(1, prConversation.allMessages().size());\n\n            var pushEmail = pushConversation.first();\n            assertEquals(listAddress, pushEmail.sender());\n            assertEquals(EmailAddress.from(\"testauthor\", \"ta@none.none\"), pushEmail.author());\n            assertEquals(pushEmail.recipients(), List.of(listAddress));\n            assertTrue(pushEmail.subject().contains(\"23456789: More fixes\"));\n        }\n    }\n\n    @Test\n    void testMailingListMergePR(TestInfo testInfo) throws IOException {\n        try (var listServer = TestMailmanServer.createV3();\n             var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var listAddress = listServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(), listServer.getSMTP(), Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var sender = EmailAddress.from(\"duke\", \"duke@duke.duke\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n            var updater = MailingListNotifier.newBuilder()\n                                             .server(mailmanServer)\n                                             .recipient(listAddress)\n                                             .sender(sender)\n                                             .reportNewTags(false)\n                                             .reportNewBranches(false)\n                                             .reportNewBuilds(false)\n                                             .mode(MailingListNotifier.Mode.PR)\n                                             .build();\n            updater.attachTo(notifyBot);\n\n            // One mail should be sent on first commit\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var editHash1 = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\",\n                                                                \"first_author\", \"first@author.example.com\");\n            localRepo.push(editHash1, repo.authenticatedUrl(), \"edit\");\n            CheckableRepository.appendAndCommit(localRepo, \"And another line\", \"12345678: And more fixes\",\n                                                \"second_author\", \"second@author.example.com\");\n            var editHash2 = CheckableRepository.appendAndCommit(localRepo, \"Yet another line\", \"3456789A: Even more fixes\",\n                                                                \"another_author\", \"another@author.example.com\");\n            localRepo.checkout(editHash1, true);\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Something else\");\n            localRepo.add(unrelated);\n            localRepo.commit(\"Unrelated\", \"unrelated_author\", \"unrelated@author.example.com\");\n            localRepo.merge(editHash2);\n            var mergeHash = localRepo.commit(\"Merge\", \"merge_author\", \"merge@author.example.com\");\n            localRepo.push(mergeHash, repo.authenticatedUrl(), \"edit\");\n\n            var pr = credentials.createPullRequest(repo, \"master\", \"edit\", \"Merge test\");\n\n            // PR hasn't been integrated yet, so there should be no mail\n            TestBotRunner.runPeriodicItems(notifyBot);\n            assertThrows(RuntimeException.class, () -> listServer.processIncoming(Duration.ofMillis(1)));\n\n            // Simulate an RFR email\n            var rfr = Email.create(\"[repo/branch] RFR: Merge test\", \"PR:\\n\" + pr.webUrl().toString())\n                           .author(EmailAddress.from(\"duke\", \"duke@duke.duke\"))\n                           .recipient(listAddress)\n                           .build();\n            mailmanServer.post(rfr);\n            listServer.processIncoming();\n\n            // And an integration\n            pr.addComment(\"Pushed as commit \" + mergeHash.hex() + \".\");\n            localRepo.push(mergeHash, repo.authenticatedUrl(), \"master\");\n\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            conversations.sort(Comparator.comparing(conversation -> conversation.first().subject()));\n            assertEquals(3, conversations.size());\n\n            var prConversation = conversations.get(0);\n            assertEquals(1, prConversation.allMessages().size());\n\n            var pushEmail = conversations.get(1).first();\n            if (pushEmail.body().contains(\"Initial commit\")) {\n                pushEmail = conversations.get(2).first();\n            }\n            assertEquals(listAddress, pushEmail.sender());\n            assertEquals(EmailAddress.from(\"unrelated_author\", \"unrelated@author.example.com\"), pushEmail.author());\n            assertEquals(pushEmail.recipients(), List.of(listAddress));\n            assertTrue(pushEmail.subject().contains(\"2 new changesets\"));\n        }\n    }\n\n    @Test\n    void testMailingListPROnce(TestInfo testInfo) throws IOException {\n        try (var listServer = TestMailmanServer.createV3();\n             var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.branch(masterHash, \"other\");\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var listAddress = listServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(), listServer.getSMTP(), Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var sender = EmailAddress.from(\"duke\", \"duke@duke.duke\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master|other\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n            var updater = MailingListNotifier.newBuilder()\n                                             .server(mailmanServer)\n                                             .recipient(listAddress)\n                                             .sender(sender)\n                                             .author(null)\n                                             .reportNewTags(false)\n                                             .reportNewBranches(false)\n                                             .reportNewBuilds(false)\n                                             .mode(MailingListNotifier.Mode.PR)\n                                             .build();\n            updater.attachTo(notifyBot);\n\n            // Two mails should be sent on first commit\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n            listServer.processIncoming();\n\n            localRepo.checkout(masterHash, true);\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"edit\");\n            var pr = credentials.createPullRequest(repo, \"master\", \"edit\", \"RFR: My PR\");\n\n            // PR hasn't been integrated yet, so there should be no mail\n            TestBotRunner.runPeriodicItems(notifyBot);\n            assertThrows(RuntimeException.class, () -> listServer.processIncoming(Duration.ofMillis(1)));\n\n            // Simulate an RFR email\n            var rfr = Email.create(\"RFR: My PR\", \"PR:\\n\" + pr.webUrl().toString())\n                           .author(EmailAddress.from(\"duke\", \"duke@duke.duke\"))\n                           .recipient(listAddress)\n                           .build();\n            mailmanServer.post(rfr);\n            listServer.processIncoming();\n\n            // And an integration\n            pr.addComment(\"Pushed as commit \" + editHash.hex() + \".\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\", true);\n\n            TestBotRunner.runPeriodicItems(notifyBot);\n            assertThrows(RuntimeException.class, () -> listServer.processIncoming(Duration.ofMillis(1)));\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            conversations.sort(Comparator.comparing(conversation -> conversation.first().subject()));\n            assertEquals(3, conversations.size());\n\n            var prConversation = conversations.get(0);\n            assertEquals(1, prConversation.allMessages().size());\n\n            // Now push the change to another monitored branch\n            localRepo.push(editHash, repo.authenticatedUrl(), \"other\", true);\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            // The change should now end up as a separate notification thread\n            conversations = mailmanList.conversations(Duration.ofDays(1));\n            conversations.sort(Comparator.comparing(conversation -> conversation.first().subject()));\n            assertEquals(4, conversations.size());\n\n            var pushConversation = conversations.get(2);\n            var pushEmail = pushConversation.first();\n            assertEquals(listAddress, pushEmail.sender());\n            assertEquals(EmailAddress.from(\"testauthor\", \"ta@none.none\"), pushEmail.author());\n            assertEquals(pushEmail.recipients(), List.of(listAddress));\n            assertTrue(pushEmail.subject().contains(\"23456789: More fixes\"));\n        }\n    }\n\n    @Test\n    void testMailinglistTag(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3();\n             var scratchFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var localRepoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.tag(masterHash, \"jdk-12+1\", \"Added tag 1\", \"Duke Tagger\", \"tagger@openjdk.org\");\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var listAddress = listServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(), listServer.getSMTP(), Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var sender = EmailAddress.from(\"duke\", \"duke@duke.duke\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n            var updater = MailingListNotifier.newBuilder()\n                                             .server(mailmanServer)\n                                             .recipient(listAddress)\n                                             .sender(sender)\n                                             .reportNewBranches(false)\n                                             .headers(Map.of(\"extra1\", \"value1\", \"extra2\", \"value2\"))\n                                             .build();\n            updater.attachTo(notifyBot);\n\n            var noTagsUpdater = MailingListNotifier.newBuilder()\n                                                   .server(mailmanServer)\n                                                   .recipient(listAddress)\n                                                   .sender(sender)\n                                                   .reportNewTags(false)\n                                                   .reportNewBranches(false)\n                                                   .reportNewBuilds(false)\n                                                   .build();\n            noTagsUpdater.attachTo(notifyBot);\n\n            // One mail should be sent on first commit\n            TestBotRunner.runPeriodicItems(notifyBot, scratchFolder.path());\n            listServer.processIncoming();\n\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\");\n            localRepo.fetch(repo.authenticatedUrl(), \"history:history\").orElseThrow();\n            localRepo.tag(editHash, \"jdk-12+2\", \"Added tag 2\", \"Duke Tagger\", \"tagger@openjdk.org\");\n            CheckableRepository.appendAndCommit(localRepo, \"Another line 1\", \"34567890: Even more fixes\");\n            CheckableRepository.appendAndCommit(localRepo, \"Another line 2\", \"45678901: Yet even more fixes\");\n            var editHash2 = CheckableRepository.appendAndCommit(localRepo, \"Another line 3\", \"56789012: Still even more fixes\");\n            localRepo.tag(editHash2, \"jdk-12+4\", \"Added tag 3\", \"Duke Tagger\", \"tagger@openjdk.org\");\n            CheckableRepository.appendAndCommit(localRepo, \"Another line 4\", \"67890123: Brand new fixes\");\n            var editHash3 = CheckableRepository.appendAndCommit(localRepo, \"Another line 5\", \"78901234: More brand new fixes\");\n            localRepo.tag(editHash3, \"jdk-13+0\", \"Added tag 4\", \"Duke Tagger\", \"tagger@openjdk.org\");\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            TestBotRunner.runPeriodicItems(notifyBot, scratchFolder.path());\n            listServer.processIncoming();\n            listServer.processIncoming();\n            listServer.processIncoming();\n            listServer.processIncoming();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(5, conversations.size());\n\n            for (var conversation : conversations) {\n                var email = conversation.first();\n                if (email.subject().equals(\"git: test: Added tag jdk-12+2 for changeset \" + editHash.abbreviate())) {\n                    assertTrue(email.body().contains(\"23456789: More fixes\"));\n                    assertFalse(email.body().contains(\"34567890: Even more fixes\"));\n                    assertFalse(email.body().contains(\"45678901: Yet even more fixes\"));\n                    assertFalse(email.body().contains(\"56789012: Still even more fixes\"));\n                    assertFalse(email.body().contains(\"67890123: Brand new fixes\"));\n                    assertFalse(email.body().contains(\"78901234: More brand new fixes\"));\n                    assertEquals(EmailAddress.from(\"Duke Tagger\", \"tagger@openjdk.org\"), email.author());\n                } else if (email.subject().equals(\"git: test: Added tag jdk-12+4 for changeset \" + editHash2.abbreviate())) {\n                    assertFalse(email.body().contains(\"23456789: More fixes\"));\n                    assertTrue(email.body().contains(\"34567890: Even more fixes\"));\n                    assertTrue(email.body().contains(\"45678901: Yet even more fixes\"));\n                    assertTrue(email.body().contains(\"56789012: Still even more fixes\"));\n                    assertFalse(email.body().contains(\"67890123: Brand new fixes\"));\n                    assertFalse(email.body().contains(\"78901234: More brand new fixes\"));\n                    assertEquals(EmailAddress.from(\"Duke Tagger\", \"tagger@openjdk.org\"), email.author());\n                } else if (email.subject().equals(\"git: test: Added tag jdk-13+0 for changeset \" + editHash3.abbreviate())) {\n                    assertFalse(email.body().contains(\"23456789: More fixes\"));\n                    assertFalse(email.body().contains(\"34567890: Even more fixes\"));\n                    assertFalse(email.body().contains(\"45678901: Yet even more fixes\"));\n                    assertFalse(email.body().contains(\"56789012: Still even more fixes\"));\n                    assertFalse(email.body().contains(\"67890123: Brand new fixes\"));\n                    assertTrue(email.body().contains(\"78901234: More brand new fixes\"));\n                    assertEquals(EmailAddress.from(\"Duke Tagger\", \"tagger@openjdk.org\"), email.author());\n                } else if (email.subject().equals(\"git: test: 6 new changesets\")) {\n                    assertTrue(email.body().contains(\"23456789: More fixes\"));\n                    assertTrue(email.body().contains(\"34567890: Even more fixes\"));\n                    assertTrue(email.body().contains(\"45678901: Yet even more fixes\"));\n                    assertTrue(email.body().contains(\"56789012: Still even more fixes\"));\n                    assertTrue(email.body().contains(\"67890123: Brand new fixes\"));\n                    assertTrue(email.body().contains(\"78901234: More brand new fixes\"));\n                    assertEquals(EmailAddress.from(\"testauthor\", \"ta@none.none\"), email.author());\n                } else if(email.subject().equals(\"git: test: 2 new changesets\")){\n                    assertTrue(email.body().contains(\"Initial commit\"));\n                    assertTrue(email.body().contains(\"Lock\"));\n                }\n                else {\n                    fail(\"Mismatched subject: \" + email.subject());\n                }\n                assertTrue(email.hasHeader(\"extra1\"));\n                assertEquals(\"value1\", email.headerValue(\"extra1\"));\n                assertTrue(email.hasHeader(\"extra2\"));\n                assertEquals(\"value2\", email.headerValue(\"extra2\"));\n            }\n        }\n    }\n\n    @Test\n    void testMailinglistPlainTags(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var listServer = TestMailmanServer.createV3()) {\n            var repo = credentials.getHostedRepository();\n            var localRepoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.tag(masterHash, \"jdk-12+1\", \"Added tag 1\", \"Duke Tagger\", \"tagger@openjdk.org\");\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var listAddress = listServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(), listServer.getSMTP(), Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var sender = EmailAddress.from(\"duke\", \"duke@duke.duke\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n            var updater = MailingListNotifier.newBuilder()\n                                             .server(mailmanServer)\n                                             .recipient(listAddress)\n                                             .sender(sender)\n                                             .reportNewBranches(false)\n                                             .reportNewBuilds(false)\n                                             .headers(Map.of(\"extra1\", \"value1\", \"extra2\", \"value2\"))\n                                             .build();\n            updater.attachTo(notifyBot);\n            var noTagsUpdater = MailingListNotifier.newBuilder()\n                                                   .server(mailmanServer)\n                                                   .recipient(listAddress)\n                                                   .sender(sender)\n                                                   .reportNewTags(false)\n                                                   .reportNewBranches(false)\n                                                   .reportNewBuilds(false)\n                                                   .build();\n            noTagsUpdater.attachTo(notifyBot);\n\n            // One mail should be sent on first commit\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\");\n            localRepo.fetch(repo.authenticatedUrl(), \"history:history\").orElseThrow();\n            localRepo.tag(editHash, \"jdk-12+2\", \"Added tag 2\", \"Duke Tagger\", \"tagger@openjdk.org\");\n            CheckableRepository.appendAndCommit(localRepo, \"Another line 1\", \"34567890: Even more fixes\");\n            CheckableRepository.appendAndCommit(localRepo, \"Another line 2\", \"45678901: Yet even more fixes\");\n            var editHash2 = CheckableRepository.appendAndCommit(localRepo, \"Another line 3\", \"56789012: Still even more fixes\");\n            localRepo.tag(editHash2, \"jdk-12+4\", \"Added tag 3\", \"Duke Tagger\", \"tagger@openjdk.org\");\n            CheckableRepository.appendAndCommit(localRepo, \"Another line 4\", \"67890123: Brand new fixes\");\n            var editHash3 = CheckableRepository.appendAndCommit(localRepo, \"Another line 5\", \"78901234: More brand new fixes\");\n            localRepo.tag(editHash3, \"jdk-13+0\", \"Added tag 4\", \"Duke Tagger\", \"tagger@openjdk.org\");\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n            listServer.processIncoming();\n            listServer.processIncoming();\n            listServer.processIncoming();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(5, conversations.size());\n\n            for (var conversation : conversations) {\n                var email = conversation.first();\n                if (email.subject().equals(\"git: test: Added tag jdk-12+2 for changeset \" + editHash.abbreviate())) {\n                    assertEquals(EmailAddress.from(\"Duke Tagger\", \"tagger@openjdk.org\"), email.author());\n                } else if (email.subject().equals(\"git: test: Added tag jdk-12+4 for changeset \" + editHash2.abbreviate())) {\n                    assertEquals(EmailAddress.from(\"Duke Tagger\", \"tagger@openjdk.org\"), email.author());\n                } else if (email.subject().equals(\"git: test: Added tag jdk-13+0 for changeset \" + editHash3.abbreviate())) {\n                    assertEquals(EmailAddress.from(\"Duke Tagger\", \"tagger@openjdk.org\"), email.author());\n                } else if (email.subject().equals(\"git: test: 6 new changesets\")) {\n                    assertEquals(EmailAddress.from(\"testauthor\", \"ta@none.none\"), email.author());\n                } else if(email.subject().equals(\"git: test: 2 new changesets\")){\n                    assertEquals(EmailAddress.from(\"test\", \"test@test.test\"), email.author());\n                } else {\n                    fail(\"Mismatched subject: \" + email.subject());\n                }\n                assertTrue(email.hasHeader(\"extra1\"));\n                assertEquals(\"value1\", email.headerValue(\"extra1\"));\n                assertTrue(email.hasHeader(\"extra2\"));\n                assertEquals(\"value2\", email.headerValue(\"extra2\"));\n            }\n        }\n    }\n\n    @Test\n    void testMailingListBranch(TestInfo testInfo) throws IOException {\n        try (var listServer = TestMailmanServer.createV3();\n             var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            credentials.commitLock(localRepo);\n            CheckableRepository.appendAndCommit(localRepo, \"update master branch\");\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var listAddress = listServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(), listServer.getSMTP(), Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var sender = EmailAddress.from(\"duke\", \"duke@duke.duke\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master|newbranch.\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n            var updater = MailingListNotifier.newBuilder()\n                                             .server(mailmanServer)\n                                             .recipient(listAddress)\n                                             .sender(sender)\n                                             .reportNewTags(false)\n                                             .reportNewBuilds(false)\n                                             .headers(Map.of(\"extra1\", \"value1\", \"extra2\", \"value2\"))\n                                             .build();\n            updater.attachTo(notifyBot);\n\n            // One mail should be sent on first commit\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"12345678: Some fixes\");\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"newbranch1\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            conversations.sort(Comparator.comparing(conversation -> conversation.first().subject()));\n            var email = conversations.get(1).first();\n            assertEquals(listAddress, email.sender());\n            assertEquals(EmailAddress.from(\"testauthor\", \"ta@none.none\"), email.author());\n            assertEquals(email.recipients(), List.of(listAddress));\n            assertEquals(\"git: test: created branch newbranch1 based on the branch master containing 2 unique commits\", email.subject());\n            assertTrue(email.body().contains(\"12345678: Some fixes\"));\n            assertTrue(email.hasHeader(\"extra1\"));\n            assertEquals(\"value1\", email.headerValue(\"extra1\"));\n            assertTrue(email.hasHeader(\"extra2\"));\n            assertEquals(\"value2\", email.headerValue(\"extra2\"));\n\n            TestBotRunner.runPeriodicItems(notifyBot);\n            assertThrows(RuntimeException.class, () -> listServer.processIncoming(Duration.ofMillis(1)));\n\n            localRepo.push(editHash, repo.authenticatedUrl(), \"newbranch2\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            conversations = mailmanList.conversations(Duration.ofDays(1));\n            conversations.sort(Comparator.comparing(conversation -> conversation.first().subject()));\n            email = conversations.get(2).first();\n            assertEquals(listAddress, email.sender());\n            assertEquals(sender, email.author());\n            assertEquals(email.recipients(), List.of(listAddress));\n            assertEquals(\"git: test: created branch newbranch2 based on the branch newbranch1 containing 0 unique commits\", email.subject());\n            assertEquals(\"The new branch newbranch2 is currently identical to the newbranch1 branch.\", email.body());\n        }\n    }\n\n    @Test\n    void testMailingListNoIdempotence(TestInfo testInfo) throws IOException {\n        try (var listServer = TestMailmanServer.createV3();\n             var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var listAddress = listServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(), listServer.getSMTP(), Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var sender = EmailAddress.from(\"duke\", \"duke@duke.duke\");\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .build();\n            var updater = MailingListNotifier.newBuilder()\n                                             .server(mailmanServer)\n                                             .recipient(listAddress)\n                                             .sender(sender)\n                                             .reportNewTags(false)\n                                             .reportNewBranches(false)\n                                             .reportNewBuilds(false)\n                                             .headers(Map.of(\"extra1\", \"value1\", \"extra2\", \"value2\"))\n                                             .allowedAuthorDomains(Pattern.compile(\"none\"))\n                                             .build();\n            updater.attachTo(notifyBot);\n\n            // One mail should be sent on first commit\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            // Save history state\n            var historyHash = localRepo.fetch(repo.authenticatedUrl(), \"history\").orElseThrow();\n\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(2, conversations.size());\n\n            // Reset the history\n            localRepo.push(historyHash, repo.authenticatedUrl(), \"history\", true);\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            // There should now be a duplicate mail\n            conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(3, conversations.size());\n        }\n    }\n\n    @Test\n    void testMailingListWithExistingRepo(TestInfo testInfo) throws IOException {\n        try (var listServer = TestMailmanServer.createV3();\n             var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var listAddress = listServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(listServer.getArchive(), listServer.getSMTP(), Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var sender = EmailAddress.from(\"duke\", \"duke@duke.duke\");\n            var notifyBot = NotifyBot.newBuilder()\n                    .repository(repo)\n                    .storagePath(storageFolder)\n                    .branches(Pattern.compile(\"master\"))\n                    .tagStorageBuilder(tagStorage)\n                    .branchStorageBuilder(branchStorage)\n                    .prStateStorageBuilder(prStateStorage)\n                    .build();\n            var updater = MailingListNotifier.newBuilder()\n                    .server(mailmanServer)\n                    .recipient(listAddress)\n                    .sender(sender)\n                    .reportNewTags(false)\n                    .reportNewBranches(false)\n                    .reportNewBuilds(false)\n                    .headers(Map.of(\"extra1\", \"value1\", \"extra2\", \"value2\"))\n                    .allowedAuthorDomains(Pattern.compile(\"none\"))\n                    .build();\n            updater.attachTo(notifyBot);\n\n            CheckableRepository.appendAndCommit(localRepo,\"commit1\", \"commit1\");\n            CheckableRepository.appendAndCommit(localRepo,\"commit2\", \"commit2\");\n            var updateHash = CheckableRepository.appendAndCommit(localRepo,\"commit3\", \"commit3\");\n            localRepo.push(updateHash,repo.authenticatedUrl(),\"master\");\n\n            // No mail should be sent on first commit because it has a long history(commit count > 5)\n            TestBotRunner.runPeriodicItems(notifyBot);\n            assertThrows(RuntimeException.class, () -> listServer.processIncoming());\n\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"23456789: More fixes\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n            listServer.processIncoming();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            conversations.sort(Comparator.comparing(conversation -> conversation.first().subject()));\n            // get the latest email\n            var email = conversations.get(0).first();\n            assertEquals(listAddress, email.sender());\n            assertEquals(sender, email.author());\n            assertEquals(email.recipients(), List.of(listAddress));\n            assertTrue(email.subject().contains(\": 23456789: More fixes\"));\n            assertFalse(email.subject().contains(\"master\"));\n            assertTrue(email.body().contains(\"Changeset: \" + editHash.abbreviate()));\n            assertTrue(email.body().contains(\"23456789: More fixes\"));\n            assertFalse(email.body().contains(\"Committer\"));\n            assertFalse(email.body().contains(masterHash.abbreviate()));\n            assertTrue(email.hasHeader(\"extra1\"));\n            assertEquals(\"value1\", email.headerValue(\"extra1\"));\n            assertTrue(email.hasHeader(\"extra2\"));\n            assertEquals(\"value2\", email.headerValue(\"extra2\"));\n            assertTrue(email.hasHeader(\"X-Git-URL\"));\n            assertEquals(repo.webUrl().toString(), email.headerValue(\"X-Git-URL\"));\n            assertTrue(email.hasHeader(\"X-Git-Changeset\"));\n            assertEquals(editHash.hex(), email.headerValue(\"X-Git-Changeset\"));\n        }\n    }\n\n}\n"
  },
  {
    "path": "bots/notify/src/test/java/org/openjdk/skara/bots/notify/notes/CommitNoteNotiferTests.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.notes;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.bots.notify.NotifyBot;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.openjdk.skara.bots.notify.TestUtils.*;\n\npublic class CommitNoteNotiferTests {\n    @Test\n    void testCommitNote(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var issueProject = credentials.getIssueProject();\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .integratorId(repo.forge().currentUser().id())\n                                     .build();\n            // Register a RepositoryListener to make history initialize on the first run\n            notifyBot.registerRepositoryListener(new NullRepositoryListener());\n            var notifier = new CommitNoteNotifier(issueProject);\n            notifier.attachTo(notifyBot);\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Save the state\n            var historyState = localRepo.fetch(repo.authenticatedUrl(), \"history\");\n\n            // \"Fake\" an integrated pull request\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", \"Fix an issue\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            var pr = credentials.createPullRequest(repo, \"master\", \"master\", \"Fix an issue\");\n            pr.setBody(\"I made a fix\");\n            pr.addLabel(\"integrated\");\n            pr.addComment(\"More text!\\n\\n@user Pushed as commit \" + editHash.hex() + \". Even more text.\\n\\nAnd some additional text.\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Check commit note\n            var remoteCommit = repo.commit(editHash).orElseThrow();\n            localRepo.fetch(repo.authenticatedUrl(), \"refs/notes/*:refs/notes/*\");\n            var note = localRepo.notes(editHash);\n            assertEquals(List.of(\"Commit: \" + remoteCommit.webUrl(),\n                                 \"Review: \" + pr.webUrl()),\n                         note);\n        }\n    }\n\n    @Test\n    void testCommitNoteWithIssues(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var tagStorage = createTagStorage(repo);\n            var branchStorage = createBranchStorage(repo);\n            var prStateStorage = createPullRequestStateStorage(repo);\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n\n            var issueProject = credentials.getIssueProject();\n            var issue = issueProject.createIssue(\"A title\",\n                                                 List.of(\"A description\"),\n                                                 Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var commitMessageTitle = issue.id() + \": A title\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Change\", commitMessageTitle);\n\n            var notifyBot = NotifyBot.newBuilder()\n                                     .repository(repo)\n                                     .storagePath(storageFolder)\n                                     .branches(Pattern.compile(\"master\"))\n                                     .tagStorageBuilder(tagStorage)\n                                     .branchStorageBuilder(branchStorage)\n                                     .prStateStorageBuilder(prStateStorage)\n                                     .integratorId(repo.forge().currentUser().id())\n                                     .build();\n            // Register a RepositoryListener to make history initialize on the first run\n            notifyBot.registerRepositoryListener(new NullRepositoryListener());\n            var notifier = new CommitNoteNotifier(issueProject);\n            notifier.attachTo(notifyBot);\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Save the state\n            var historyState = localRepo.fetch(repo.authenticatedUrl(), \"history\");\n\n            // \"Fake\" a fix\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            var pr = credentials.createPullRequest(repo, \"master\", \"master\", commitMessageTitle);\n            pr.setBody(\"\\n\\n### Issue\\n * [\" + issue.id() + \"](https://openjdk.org): The issue\");\n            pr.addLabel(\"integrated\");\n            pr.addComment(\"Pushed as commit \" + editHash.hex() + \".\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Check commit note\n            var remoteCommit = repo.commit(editHash).orElseThrow();\n            localRepo.fetch(repo.authenticatedUrl(), \"refs/notes/*:refs/notes/*\");\n            var note = localRepo.notes(editHash);\n            assertEquals(List.of(\"Commit: \" + remoteCommit.webUrl(),\n                                 \"Review: \" + pr.webUrl(),\n                                 \"Issues:\",\n                                 \"- \" + issue.webUrl()),\n                         note);\n        }\n    }\n}\n"
  },
  {
    "path": "bots/notify/src/test/java/org/openjdk/skara/bots/notify/prbranch/PullRequestBranchNotifierTests.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.notify.prbranch;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.Branch;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class PullRequestBranchNotifierTests {\n    private TestBotFactory testBotBuilder(HostedRepository hostedRepository, Path storagePath) {\n        return TestBotFactory.newBuilder()\n                             .addHostedRepository(\"hostedrepo\", hostedRepository)\n                             .storagePath(storagePath)\n                             .addConfiguration(\"database\", JSON.object()\n                                                               .put(\"repository\", \"hostedrepo:history\")\n                                                               .put(\"name\", \"duke\")\n                                                               .put(\"email\", \"duke@openjdk.org\"))\n                             .addConfiguration(\"ready\", JSON.object()\n                                                            .put(\"labels\", JSON.array())\n                                                            .put(\"comments\", JSON.array()))\n                             .addConfiguration(\"integrator\", JSON.of(hostedRepository.forge().currentUser().id()))\n                             .addConfiguration(\"repositories\", JSON.object()\n                                                                   .put(\"hostedrepo\", JSON.object()\n                                                                                          .put(\"basename\", \"test\")\n                                                                                          .put(\"branches\", \"master\")\n                                                                                          .put(\"prbranch\", JSON.object().put(\"protect\", true))))\n                             .build();\n    }\n\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var notifyBot = testBotBuilder(repo, storageFolder).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create a PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"source\", true);\n            var pr = credentials.createPullRequest(repo, \"master\", \"source\", \"This is a PR\", false);\n            pr.addLabel(\"rfr\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The target repo should now contain the new branch\n            var hash = localRepo.fetch(repo.authenticatedUrl(), PreIntegrations.preIntegrateBranch(pr)).orElseThrow();\n            assertEquals(editHash, hash);\n\n            // Close the PR\n            pr.setState(Issue.State.CLOSED);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The target repo should no longer contain the branch\n            assertThrows(IOException.class, () -> localRepo.fetch(repo.authenticatedUrl(), PreIntegrations.preIntegrateBranch(pr)).orElseThrow());\n\n            // Reopen the PR\n            pr.setState(Issue.State.OPEN);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The branch should have reappeared\n            hash = localRepo.fetch(repo.authenticatedUrl(), PreIntegrations.preIntegrateBranch(pr)).orElseThrow();\n            assertEquals(editHash, hash);\n        }\n    }\n\n    @Test\n    void rfrMissing(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var notifyBot = testBotBuilder(repo, storageFolder).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create a PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"source\", true);\n            var pr = credentials.createPullRequest(repo, \"master\", \"source\", \"This is a PR\", false);\n            pr.addLabel(\"rfr\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The target repo should now contain the new branch\n            var hash = localRepo.fetch(repo.authenticatedUrl(), PreIntegrations.preIntegrateBranch(pr)).orElseThrow();\n            assertEquals(editHash, hash);\n\n            // remove label `rfr`\n            pr.removeLabel(\"rfr\");\n\n            // Close the PR\n            pr.setState(Issue.State.CLOSED);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The target repo should no longer contain the branch\n            assertThrows(IOException.class, () -> localRepo.fetch(repo.authenticatedUrl(), PreIntegrations.preIntegrateBranch(pr)).orElseThrow());\n\n            // Reopen the PR\n            pr.setState(Issue.State.OPEN);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The target repo should not contain the branch, because the pr doesn't have label `rfr`.\n            assertThrows(IOException.class, () -> localRepo.fetch(repo.authenticatedUrl(), PreIntegrations.preIntegrateBranch(pr)).orElseThrow());\n\n            // add label `rfr`\n            pr.addLabel(\"rfr\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The branch should have reappeared\n            hash = localRepo.fetch(repo.authenticatedUrl(), PreIntegrations.preIntegrateBranch(pr)).orElseThrow();\n            assertEquals(editHash, hash);\n        }\n    }\n\n    @Test\n    void updated(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var notifyBot = testBotBuilder(repo, storageFolder).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create a PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"source\", true);\n            var pr = credentials.createPullRequest(repo, \"master\", \"source\", \"This is a PR\", false);\n            pr.addLabel(\"rfr\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The target repo should now contain the new branch\n            var hash = localRepo.fetch(repo.authenticatedUrl(), PreIntegrations.preIntegrateBranch(pr)).orElseThrow();\n            assertEquals(editHash, hash);\n\n            // Push another change\n            var updatedHash = CheckableRepository.appendAndCommit(localRepo, \"Yet another line\");\n            localRepo.push(updatedHash, repo.authenticatedUrl(), \"source\");\n\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The branch should have been updated\n            hash = localRepo.fetch(repo.authenticatedUrl(), PreIntegrations.preIntegrateBranch(pr)).orElseThrow();\n            assertEquals(updatedHash, hash);\n        }\n    }\n\n    @Test\n    void branchMissing(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var notifyBot = testBotBuilder(repo, storageFolder).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create a PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"source\", true);\n            var pr = credentials.createPullRequest(repo, \"master\", \"source\", \"This is a PR\", false);\n            pr.addLabel(\"rfr\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The target repo should now contain the new branch\n            var hash = localRepo.fetch(repo.authenticatedUrl(), PreIntegrations.preIntegrateBranch(pr)).orElseThrow();\n            assertEquals(editHash, hash);\n            try {\n                localRepo.prune(new Branch(PreIntegrations.preIntegrateBranch(pr)), repo.authenticatedUrl().toString());\n            } catch (IOException ignored) {\n            }\n\n            // Now close it - no exception should be raised\n            pr.setState(Issue.State.CLOSED);\n            TestBotRunner.runPeriodicItems(notifyBot);\n        }\n    }\n\n    @Test\n    void retarget(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var notifyBot = testBotBuilder(repo, storageFolder).create(\"notify\", JSON.object());\n\n            // Initialize history\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Create a PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"source\", true);\n            var pr = credentials.createPullRequest(repo, \"master\", \"source\", \"This is a PR\", false);\n            pr.addLabel(\"rfr\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The target repo should now contain the new branch\n            var hash = localRepo.fetch(repo.authenticatedUrl(), PreIntegrations.preIntegrateBranch(pr)).orElseThrow();\n            assertEquals(editHash, hash);\n\n            // Create follow-up work\n            var followUp = CheckableRepository.appendAndCommit(localRepo, \"Follow-up work\", \"Follow-up change\");\n            localRepo.push(followUp, repo.authenticatedUrl(), \"followup\", true);\n            var followUpPr = credentials.createPullRequest(repo, PreIntegrations.preIntegrateBranch(pr), \"followup\", \"This is another pull request\");\n            followUpPr.addLabel(\"rfr\");\n            assertEquals(PreIntegrations.preIntegrateBranch(pr), followUpPr.targetRef());\n\n            // Close the PR\n            pr.setState(Issue.State.CLOSED);\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The target repo should no longer contain the branch\n            assertThrows(IOException.class, () -> localRepo.fetch(repo.authenticatedUrl(), PreIntegrations.preIntegrateBranch(pr)).orElseThrow());\n\n            // The follow-up PR should have been retargeted\n            assertEquals(\"master\", followUpPr.store().targetRef());\n\n            // Instructions on how to adapt to the newly integrated changes should have been posted\n            var lastComment = followUpPr.comments().getLast();\n            assertTrue(lastComment.body().contains(\"The parent pull request that this pull request \"\n                    + \"depends on has been closed without being integrated\"), lastComment.body());\n\n            // Create another follow-up work\n            var anotherFollowUp = CheckableRepository.appendAndCommit(localRepo, \"another follow-up work\", \"another follow-up change\");\n            localRepo.push(anotherFollowUp, repo.authenticatedUrl(), \"another-followup\", true);\n            var anotherFollowUpPr = credentials.createPullRequest(repo, PreIntegrations.preIntegrateBranch(followUpPr), \"another-followup\", \"This is another follow-up pull request\");\n            anotherFollowUpPr.addLabel(\"rfr\");\n            assertEquals(PreIntegrations.preIntegrateBranch(followUpPr), anotherFollowUpPr.targetRef());\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // Simulate that the PR has been integrated.\n            followUpPr.setState(Issue.State.CLOSED);\n            followUpPr.addLabel(\"integrated\");\n            TestBotRunner.runPeriodicItems(notifyBot);\n\n            // The target repo should no longer contain the branch\n            var targetBranch = PreIntegrations.preIntegrateBranch(followUpPr);\n            assertThrows(IOException.class, () -> localRepo.fetch(repo.authenticatedUrl(), targetBranch).orElseThrow());\n\n            // The another follow-up PR should have been retargeted\n            assertEquals(\"master\", anotherFollowUpPr.store().targetRef());\n            lastComment = anotherFollowUpPr.comments().getLast();\n            assertTrue(lastComment.body().contains(\"The parent pull request that this \"\n                    + \"pull request depends on has now been integrated\"), lastComment.body());\n            assertTrue(lastComment.body().contains(\"git checkout another-followup\"), lastComment.body());\n            assertTrue(lastComment.body().contains(\"git commit -m \\\"Merge master\\\"\"), lastComment.body());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.pr'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.bots.pr' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':ci')\n    implementation project(':bot')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':vcs')\n    implementation project(':jcheck')\n    implementation project(':host')\n    implementation project(':census')\n    implementation project(':ini')\n    implementation project(':process')\n    implementation project(':json')\n    implementation project(':email')\n    implementation project(':metrics')\n    implementation project(':jbs')\n    implementation project(':network')\n    implementation project(':bots:common')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nimport org.openjdk.skara.bots.pr.PullRequestBotFactory;\n\nmodule org.openjdk.skara.bots.pr {\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.jcheck;\n    requires org.openjdk.skara.host;\n    requires org.openjdk.skara.census;\n    requires org.openjdk.skara.ini;\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.process;\n    requires org.openjdk.skara.email;\n    requires org.openjdk.skara.jbs;\n    requires org.openjdk.skara.network;\n    requires org.openjdk.skara.bots.common;\n    requires java.logging;\n\n    exports org.openjdk.skara.bots.pr;\n    provides org.openjdk.skara.bot.BotFactory with PullRequestBotFactory;\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/AdditionalConfiguration.java",
    "content": "/*\n * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.jcheck.JCheckConfiguration;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.IOException;\nimport java.util.*;\n\npublic class AdditionalConfiguration {\n    static List<String> get(JCheckConfiguration original, HostUser botUser, List<Comment> comments, MergePullRequestReviewConfiguration reviewMerge) throws IOException {\n        var ret = new ArrayList<String>();\n        var additionalReviewers = ReviewersTracker.additionalRequiredReviewers(botUser, comments);\n        if (additionalReviewers.isEmpty() && reviewMerge == MergePullRequestReviewConfiguration.JCHECK) {\n            return ret;\n        }\n\n        if (additionalReviewers.isEmpty()) {\n            additionalReviewers = Optional.of(new ReviewersTracker.AdditionalRequiredReviewers(0, \"\"));\n        }\n        var updatedLimits = ReviewersTracker.updatedRoleLimits(original, additionalReviewers.get().number(), additionalReviewers.get().role());\n        ret.add(\"[checks \\\"reviewers\\\"]\");\n        updatedLimits.forEach((role, count) -> ret.add(role + \"=\" + count));\n        ret.add(\"minimum=disable\");\n        if (reviewMerge == MergePullRequestReviewConfiguration.ALWAYS) {\n            ret.add(\"merge=check\");\n        } else if (reviewMerge == MergePullRequestReviewConfiguration.NEVER) {\n            ret.add(\"merge=ignore\");\n        }\n        return ret;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/Approval.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.regex.Pattern;\n\npublic class Approval {\n    private final String prefix;\n    private final String request;\n    private final String approved;\n    private final String rejected;\n    private final String documentLink;\n    private final Map<Pattern, String> branchPrefixes;\n    private final boolean approvalComment;\n    private final String approvalTerm;\n\n    public Approval(String prefix, String request, String approved, String rejected, String documentLink, boolean approvalComment, String approvalTerm) {\n        this.prefix = prefix;\n        this.request = request;\n        this.approved = approved;\n        this.rejected = rejected;\n        this.branchPrefixes = new HashMap<>();\n        this.documentLink = documentLink;\n        this.approvalComment = approvalComment;\n        this.approvalTerm = approvalTerm;\n    }\n\n    public void addBranchPrefix(Pattern branchPattern, String prefix) {\n        branchPrefixes.put(branchPattern, prefix);\n    }\n\n    public String requestedLabel(String targetRef) {\n        return prefixForRef(targetRef) + request;\n    }\n\n    public String approvedLabel(String targetRef) {\n        return prefixForRef(targetRef) + approved;\n    }\n\n    public String rejectedLabel(String targetRef) {\n        return prefixForRef(targetRef) + rejected;\n    }\n\n    public String documentLink() {\n        return documentLink;\n    }\n\n    private String prefixForRef(String targetRef) {\n        String prefix = this.prefix;\n        for (var entry : branchPrefixes.entrySet()) {\n            if (entry.getKey().matcher(targetRef).matches()) {\n                prefix = entry.getValue();\n                break;\n            }\n        }\n        return prefix;\n    }\n\n    public boolean needsApproval(String targetRef) {\n        if (branchPrefixes.isEmpty()) {\n            return true;\n        }\n        for (var branchPattern : branchPrefixes.keySet()) {\n            if (branchPattern.matcher(targetRef).matches()) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    public boolean approvalComment() {\n        return approvalComment;\n    }\n\n    public String approvalTerm() {\n        return approvalTerm;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/ApprovalCommand.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.PreIntegrations;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.io.PrintWriter;\nimport java.util.List;\nimport java.util.regex.Pattern;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.approval;\nimport static org.openjdk.skara.bots.pr.ApproveCommand.getIssues;\n\npublic class ApprovalCommand implements CommandHandler {\n    @Override\n    public String description() {\n        return \"request for maintainer's approval\";\n    }\n\n    @Override\n    public String name() {\n        return approval.name();\n    }\n\n    private static final Pattern APPROVAL_ARG_PATTERN = Pattern.compile(\"(([A-Za-z]+-)?[0-9]+)? ?(request|cancel)(.*?)?\", Pattern.MULTILINE | Pattern.DOTALL);\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        if (!command.user().equals(pr.author())) {\n            reply.println(\"Only the author (@\" + pr.author().username() + \") is allowed to issue the `/approval` command.\");\n            return;\n        }\n        var approval = bot.approval();\n        var targetRef = PreIntegrations.realTargetRef(pr);\n        if (approval == null) {\n            reply.println(\"Changes in this repository do not require maintainer approval.\");\n            return;\n        }\n        if (!approval.needsApproval(targetRef)) {\n            reply.println(\"Changes to branch \" + targetRef + \" do not require maintainer approval\");\n            return;\n        }\n        var argMatcher = APPROVAL_ARG_PATTERN.matcher(command.args());\n        if (!argMatcher.matches()) {\n            showHelp(reply);\n            return;\n        }\n\n        var issueProject = bot.issueProject();\n        String issueId = argMatcher.group(1);\n        String option = argMatcher.group(3);\n        String message = argMatcher.group(4);\n\n        var issues = getIssues(issueId, pr, allComments, reply);\n        if (issues.isEmpty()) {\n            return;\n        }\n        reply.println();\n        for (var issue : issues) {\n            reply.print(issue.id() + \": \");\n            if (issue.project().isPresent() && !issue.project().get().equalsIgnoreCase(issueProject.name())) {\n                reply.println(\"Approval can only be requested for issues in the \" + issueProject.name() + \" project.\");\n                continue;\n            }\n\n            var issueTrackerIssueOpt = issueProject.issue(issue.shortId());\n            if (issueTrackerIssueOpt.isEmpty()) {\n                reply.println(\"Can not be found in the \" + issueProject.name() + \" project.\");\n                continue;\n            }\n            var issueTrackerIssue = issueTrackerIssueOpt.get();\n            var requestLabel = approval.requestedLabel(targetRef);\n            var approvedLabel = approval.approvedLabel(targetRef);\n            var rejectedLabel = approval.rejectedLabel(targetRef);\n            var prefix = \"[\" + requestLabel + \"]\";\n            var comments = issueTrackerIssue.comments();\n            var existingComment = comments.stream()\n                    .filter(comment -> comment.author().equals(issueProject.issueTracker().currentUser()))\n                    .filter(comment -> comment.body().startsWith(prefix))\n                    .findFirst();\n\n            var labels = issueTrackerIssue.labelNames();\n            if (option.equals(\"cancel\")) {\n                if (labels.contains(approvedLabel) || labels.contains(rejectedLabel)) {\n                    reply.println(\"The request has already been handled by a maintainer and can no longer be canceled.\");\n                } else {\n                    issueTrackerIssue.removeLabel(requestLabel);\n                    existingComment.ifPresent(issueTrackerIssue::removeComment);\n                    reply.println(\"The approval request has been cancelled successfully.\");\n                }\n            } else if (option.equals(\"request\")) {\n                if (labels.contains(approvedLabel)) {\n                    reply.println(\"Approval has already been requested and approved.\");\n                } else if (labels.contains(rejectedLabel)) {\n                    reply.println(\"Approval has already been requested and rejected.\");\n                } else {\n                    var messageToPost = prefix + \" Approval Request from \" + command.user().fullName() + \"\\n\" + message.trim();\n                    if (existingComment.isPresent()) {\n                        if (!existingComment.get().body().equals(messageToPost)) {\n                            Comment comment = issueTrackerIssue.updateComment(existingComment.get().id(), messageToPost);\n                            reply.println(\"The approval [request](\" + issueTrackerIssue.commentUrl(comment) + \") has been updated successfully.\");\n                        } else {\n                            reply.println(\"The approval [request](\" + issueTrackerIssue.commentUrl(existingComment.get()) + \") was already up to date.\");\n                        }\n                    } else {\n                        Comment comment = issueTrackerIssue.addComment(messageToPost);\n                        reply.println(\"The approval [request](\" + issueTrackerIssue.commentUrl(comment) + \") has been created successfully.\");\n                    }\n                    issueTrackerIssue.addLabel(requestLabel);\n                }\n            }\n        }\n    }\n\n    @Override\n    public boolean multiLine() {\n        return true;\n    }\n\n    private void showHelp(PrintWriter reply) {\n        reply.println(\"usage: `/approval [<id>] (request|cancel) [<text>]`\");\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/ApproveCommand.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.bots.common.SolvesTracker;\nimport org.openjdk.skara.forge.PreIntegrations;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.vcs.openjdk.Issue;\n\nimport java.io.PrintWriter;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.regex.Pattern;\n\npublic class ApproveCommand implements CommandHandler {\n\n    private static final Pattern APPROVE_ARG_PATTERN = Pattern.compile(\"(([A-Za-z]+-)?[0-9]+)? ?(yes|no)\");\n\n    @Override\n    public String description() {\n        return null;\n    }\n\n    @Override\n    public String name() {\n        return null;\n    }\n\n    private void showHelp(PrintWriter reply) {\n        reply.println(\"usage: `/approve [<id>] (yes|no)`\");\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        if (!bot.integrators().contains(command.user().username())) {\n            reply.println(\"Only integrators for this repository are allowed to issue the `/approve` command.\");\n            return;\n        }\n\n        var approval = bot.approval();\n        var targetRef = PreIntegrations.realTargetRef(pr);\n        if (approval == null) {\n            reply.println(\"Changes in this repository do not require maintainer approval.\");\n            return;\n        }\n        if (!approval.needsApproval(targetRef)) {\n            reply.println(\"Changes to branch \" + targetRef + \" do not require maintainer approval\");\n            return;\n        }\n        var argMatcher = APPROVE_ARG_PATTERN.matcher(command.args());\n        if (!argMatcher.matches()) {\n            showHelp(reply);\n            return;\n        }\n        var issueProject = bot.issueProject();\n        String issueId = argMatcher.group(1);\n        String option = argMatcher.group(3);\n\n        var issues = getIssues(issueId, pr, allComments, reply);\n        if (issues.isEmpty()) {\n            return;\n        }\n        reply.println();\n        for (var issue : issues) {\n            reply.print(issue.id() + \": \");\n            if (issue.project().isPresent() && !issue.project().get().equalsIgnoreCase(issueProject.name())) {\n                reply.println(\"Can only approve issues in the \" + issueProject.name() + \" project.\");\n                continue;\n            }\n\n            var issueTrackerIssueOpt = issueProject.issue(issue.shortId());\n            if (issueTrackerIssueOpt.isEmpty()) {\n                reply.println(\"Can not find \" + issue.id() + \" in the \" + issueProject.name() + \" project.\");\n                continue;\n            }\n\n            var issueTrackerIssue = issueTrackerIssueOpt.get();\n            var approvedLabel = approval.approvedLabel(targetRef);\n            var rejectedLabel = approval.rejectedLabel(targetRef);\n            var requestLabel = approval.requestedLabel(targetRef);\n            var labels = issueTrackerIssue.labelNames();\n\n            if (!labels.contains(requestLabel)) {\n                reply.println(\"There is no maintainer approval request for this issue.\");\n                continue;\n            }\n\n            if (option.equals(\"yes\")) {\n                issueTrackerIssue.removeLabel(rejectedLabel);\n                issueTrackerIssue.addLabel(approvedLabel);\n                reply.println(\"The approval request has been approved.\");\n            } else if (option.equals(\"no\")) {\n                issueTrackerIssue.removeLabel(approvedLabel);\n                issueTrackerIssue.addLabel(rejectedLabel);\n                reply.println(\"The approval request has been rejected.\");\n            }\n        }\n    }\n\n    static List<Issue> getIssues(String issueId, PullRequest pr, List<Comment> allComments, PrintWriter reply) {\n        var titleIssue = Issue.fromStringRelaxed(pr.title());\n        var issueIds = new ArrayList<String>();\n        titleIssue.ifPresent(value -> issueIds.add(value.shortId()));\n        List<Issue> ret = new ArrayList<>();\n        issueIds.addAll(SolvesTracker.currentSolved(pr.repository().forge().currentUser(), allComments, pr.title())\n                .stream()\n                .map(Issue::shortId)\n                .toList());\n\n        if (issueId != null) {\n            var issue = new Issue(issueId, null);\n            if (issueIds.contains(issue.shortId())) {\n                ret.add(issue);\n            } else {\n                reply.println(\"This issue is not associated with this pull request.\");\n            }\n            // If issueId is not specified, then handle all the issues associated with this pull request\n        } else {\n            if (issueIds.size() == 0) {\n                reply.println(\"There is no issue associated with this pull request.\");\n            } else {\n                ret.addAll(issueIds.stream()\n                        .map(id -> new Issue(id, null))\n                        .toList());\n            }\n        }\n        return ret;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/AuthorCommand.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.io.PrintWriter;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.author;\n\npublic class AuthorCommand implements CommandHandler {\n    private static final Pattern COMMAND_PATTERN = Pattern.compile(\"^(set|remove)?\\\\s*(.+)?$\");\n\n    private void showHelp(PrintWriter reply) {\n        reply.println(\"Syntax: `/author [set|remove] [@user | openjdk-user | Full Name <email@address>]`. For example:\");\n        reply.println();\n        reply.println(\" * `/author set @openjdk-bot`\");\n        reply.println(\" * `/author set duke`\");\n        reply.println(\" * `/author set J. Duke <duke@openjdk.org>`\");\n        reply.println(\" * `/author @openjdk-bot`\");\n        reply.println(\" * `/author remove @openjdk-bot`\");\n        reply.println(\" * `/author remove`\");\n        reply.println();\n        reply.println(\"User names can only be used for users in the census associated with this repository. \" +\n                \"For other authors you need to supply the full name and email address.\");\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        if (!command.user().equals(pr.author())) {\n            reply.println(\"Only the pull request author (@\" + pr.author().username() + \") is allowed to issue the `author` command.\");\n            return;\n        }\n\n        if (!censusInstance.isCommitter(pr.author())) {\n            reply.println(\"Only [Committers](https://openjdk.org/bylaws#committer) are allowed to issue the `author` command.\");\n            return;\n        }\n\n        var matcher = COMMAND_PATTERN.matcher(command.args());\n        if (!matcher.matches()) {\n            showHelp(reply);\n            return;\n        }\n\n        String option = matcher.group(1);\n        if (option == null) {\n            option = \"set\";\n        }\n\n        String authorArg = matcher.group(2);\n\n        switch (option) {\n            case \"set\": {\n                if (authorArg == null) {\n                    reply.println();\n                    showHelp(reply);\n                    return;\n                }\n                var author = ContributorCommand.parseUser(authorArg, pr, censusInstance, reply);\n                if (author.isEmpty()) {\n                    reply.println();\n                    showHelp(reply);\n                    return;\n                }\n                reply.println(OverridingAuthor.setAuthorMarker(author.get()));\n                reply.println(\"Setting overriding author to `\" + author.get() + \"`. When this pull request is integrated, the overriding author will be used in the commit.\");\n                break;\n            }\n            case \"remove\": {\n                var currAuthor = OverridingAuthor.author(pr.repository().forge().currentUser(), allComments);\n                Optional<EmailAddress> author;\n                if (authorArg == null) {\n                    author = currAuthor;\n                } else {\n                    author = ContributorCommand.parseUser(authorArg, pr, censusInstance, reply);\n                    if (author.isEmpty()) {\n                        reply.println();\n                        showHelp(reply);\n                        return;\n                    }\n                }\n                if (currAuthor.isEmpty()) {\n                    reply.println(\"There is no overriding author set for this pull request.\");\n                } else {\n                    if (currAuthor.get().equals(author.get())) {\n                        reply.println(OverridingAuthor.removeAuthorMarker(author.get()));\n                        reply.println(\"Overriding author `\" + author.get() + \"` was successfully removed. When this pull request is integrated, the pull request author will be used as the author of the commit.\");\n                    } else {\n                        reply.println(\"Cannot remove `\" + author.get() + \"`, the overriding author is currently set to: `\" + currAuthor.get() + \"`\");\n                    }\n                }\n                break;\n            }\n        }\n    }\n\n    @Override\n    public String description() {\n        return \"sets an overriding author to be used in the commit when the PR is integrated\";\n    }\n\n    @Override\n    public String name() {\n        return author.name();\n    }\n\n    @Override\n    public boolean allowedInBody() {\n        return true;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/BackportCommand.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.HostedBranch;\nimport org.openjdk.skara.forge.HostedCommit;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport java.io.PrintWriter;\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Comparator;\nimport java.util.List;\nimport java.util.stream.Collectors;\nimport java.time.format.DateTimeFormatter;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.backport;\n\npublic class BackportCommand implements CommandHandler {\n    private void showHelp(PrintWriter reply) {\n        reply.println(\"Usage:  `/backport <repository> [<branch>]` \" +\n                \"or `/backport [<repository>]:<branch>`\");\n    }\n\n    private void showHelpInPR(PrintWriter reply) {\n        reply.println(\"Usage: `/backport [disable] <repository> [<branch>]` \" +\n                \"or `/backport [disable] [<repository>]:<branch>`\");\n    }\n\n    @Override\n    public String description() {\n        return \"create a backport\";\n    }\n\n    @Override\n    public String name() {\n        return backport.name();\n    }\n\n    @Override\n    public boolean allowedInCommit() {\n        return true;\n    }\n\n    private static final String INSUFFICIENT_ACCESS_WARNING = \"The backport can not be created because you don't have access to the target repository.\";\n\n    private static final int BRANCHES_LIMIT = 10;\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command,\n                       List<Comment> allComments, PrintWriter reply, List<String> labelsToAdd, List<String> labelsToRemove) {\n        if (bot.checkContributorStatusForBackportCommand() && censusInstance.contributor(command.user()).isEmpty()) {\n            printInvalidUserWarning(bot, reply);\n            return;\n        }\n\n        if (pr.isClosed() && !pr.labelNames().contains(\"integrated\")) {\n            reply.println(\"`/backport` command can not be used in a closed but not integrated pull request\");\n            return;\n        }\n\n        var args = command.args();\n        if (args.isBlank()) {\n            showHelpInPR(reply);\n            return;\n        }\n\n        var parts = args.split(\" \");\n\n        // Preprocess args to support \"repo:branch\" argument\n        if (parts[0].equals(\"disable\")) {\n            if (parts.length == 2 && parts[1].contains(\":\")) {\n                List<String> tempList = new ArrayList<>();\n                tempList.add(\"disable\");\n                tempList.addAll(Arrays.asList(parts[1].split(\":\")));\n                parts = tempList.toArray(new String[0]);\n            }\n        } else {\n            if (parts.length == 1 && parts[0].contains(\":\")) {\n                parts = parts[0].split(\":\");\n            }\n        }\n\n        boolean argIsValid = parts[0].equals(\"disable\") ? parts.length == 2 || parts.length == 3 : parts.length <= 2;\n        if (!argIsValid) {\n            showHelpInPR(reply);\n            return;\n        }\n\n        if (parts[0].equals(\"disable\")) {\n            // Remove label\n            var targetRepo = getTargetRepo(bot, parts[1], reply);\n            if (targetRepo == null) {\n                return;\n            }\n            var targetRepoName = targetRepo.name();\n\n            var targetBranch = getTargetBranch(parts, 2, targetRepo, reply);\n            if (targetBranch == null) {\n                return;\n            }\n            var targetBranchName = targetBranch.name();\n\n            var backportLabel = generateBackportLabel(targetRepoName, targetBranchName);\n            if (pr.labelNames().contains(backportLabel)) {\n                labelsToRemove.add(backportLabel);\n                reply.println(\"Backport for repo `\" + targetRepoName + \"` on branch `\" + targetBranchName + \"` was successfully disabled.\");\n            } else {\n                reply.println(\"Backport for repo `\" + targetRepoName + \"` on branch `\" + targetBranchName + \"` was already disabled.\");\n            }\n        } else {\n            // Get target repo\n            var targetRepo = getTargetRepo(bot, parts[0], reply);\n            if (targetRepo == null) {\n                return;\n            }\n            var targetRepoName = targetRepo.name();\n\n            // Get target branch\n            var targetBranch = getTargetBranch(parts, 1, targetRepo, reply);\n            if (targetBranch == null) {\n                return;\n            }\n            var targetBranchName = targetBranch.name();\n\n            if (!targetRepo.canCreatePullRequest(command.user())) {\n                reply.println(INSUFFICIENT_ACCESS_WARNING);\n                return;\n            }\n\n            // Add label\n            var backportLabel = generateBackportLabel(targetRepoName, targetBranchName);\n            if (pr.labelNames().contains(backportLabel)) {\n                reply.println(\"Backport for repo `\" + targetRepoName + \"` on branch `\" + targetBranchName + \"` has already been enabled.\");\n            } else {\n                labelsToAdd.add(backportLabel);\n                reply.print(\"Backport for repo `\" + targetRepoName + \"` on branch `\" + targetBranchName + \"` was successfully enabled and will be performed once this pull request has been integrated.\");\n                reply.println(\" Further instructions will be provided at that time.\");\n                reply.println(\"<!-- add backport \" + targetRepoName + \":\" + targetBranchName + \" -->\");\n                reply.println(\"<!-- \" + command.user().username() + \" -->\");\n            }\n        }\n    }\n\n    private String generateBackportLabel(String targetRepo, String targetBranchName) {\n        return \"backport=\" + targetRepo + \":\" + targetBranchName;\n    }\n\n    private HostedRepository getTargetRepo(PullRequestBot bot, String repoName, PrintWriter reply) {\n        var forge = bot.repo().forge();\n        if (repoName.isEmpty()) {\n            repoName = bot.repo().name();\n        }\n        var repoNameArg = repoName.replace(\"http://\", \"\")\n                .replace(\"https://\", \"\")\n                .replace(forge.hostname() + \"/\", \"\");\n        // If the arg is given with a namespace prefix, look for an exact match,\n        // otherwise cut off the namespace prefix before comparing with the forks\n        // config.\n        var includesNamespace = repoNameArg.contains(\"/\");\n        var repoNameOptional = bot.forks().keySet().stream()\n                .filter(s -> includesNamespace\n                        ? s.equals(repoNameArg)\n                        : s.substring(s.indexOf(\"/\") + 1).equals(repoNameArg))\n                .findAny();\n\n        var potentialTargetRepo = repoNameOptional.flatMap(forge::repository);\n        if (potentialTargetRepo.isEmpty()) {\n            reply.println(\"The target repository `\" + repoNameArg + \"` is not a valid target for backports. \");\n            reply.print(\"List of valid target repositories: \");\n            reply.println(String.join(\", \", bot.forks().keySet().stream()\n                    .sorted()\n                    .map(repo -> \"`\" + repo + \"`\")\n                    .toList()) + \".\");\n            reply.println(\"Supplying the organization/group prefix is optional.\");\n            var branchNamesInCurrentRepo = bot.repo().branches().stream().map(HostedBranch::name).toList();\n            if (branchNamesInCurrentRepo.contains(repoName)) {\n                reply.println();\n                reply.println(\"There is a branch `\" + repoName + \"` in the current repository `\" + bot.repo().name() + \"`.\");\n                reply.println(\"To target a backport to this branch in the current repository use:\");\n                reply.println(\"`/backport :\" + repoName + \"`\");\n            }\n            return null;\n        }\n        return potentialTargetRepo.get();\n    }\n\n    private Branch getTargetBranch(String[] parts, int index, HostedRepository targetRepo, PrintWriter reply) {\n        var targetBranchName = parts.length == index + 1 ? parts[index] : targetRepo.defaultBranchName();\n        var targetBranchHash = targetRepo.branchHash(targetBranchName);\n        if (targetBranchHash.isEmpty()) {\n            reply.println(\"The target branch `\" + targetBranchName + \"` does not exist\");\n            reply.print(\"List of valid branches: \");\n            var branches = targetRepo.branches().stream()\n                    .map(HostedBranch::name)\n                    .filter(name -> !name.startsWith(\"pr/\"))\n                    .sorted(Comparator.reverseOrder())\n                    .toList();\n            reply.println(String.join(\", \", branches.stream()\n                    .limit(BRANCHES_LIMIT)\n                    .map(branch -> \"`\" + branch + \"`\")\n                    .toList()) + (branches.size() > BRANCHES_LIMIT ? \"...\" : \".\"));\n            return null;\n        }\n        return new Branch(targetBranchName);\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, HostedCommit commit, LimitedCensusInstance censusInstance,\n                       ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        if (bot.checkContributorStatusForBackportCommand() && censusInstance.contributor(command.user()).isEmpty()\n                && !command.user().equals(bot.repo().forge().currentUser())) {\n            printInvalidUserWarning(bot, reply);\n            return;\n        }\n\n        var args = command.args();\n        if (args.isBlank()) {\n            showHelp(reply);\n            return;\n        }\n\n        var parts = args.split(\" \");\n        if (parts.length > 2) {\n            showHelp(reply);\n            return;\n        }\n\n        // Preprocess args to support \"repo:branch\" argument\n        if (parts.length == 1 && parts[0].contains(\":\")) {\n            parts = parts[0].split(\":\");\n        }\n\n        // Get target repo\n        var targetRepo = getTargetRepo(bot, parts[0], reply);\n        if (targetRepo == null) {\n            return;\n        }\n        var targetRepoName = targetRepo.name();\n        var fork = bot.forks().get(targetRepo.name());\n\n        // Get target branch\n        var targetBranch = getTargetBranch(parts, 1, targetRepo, reply);\n        if (targetBranch == null) {\n            return;\n        }\n        var targetBranchName = targetBranch.name();\n\n        // Find real user when the command user is bot\n        HostUser realUser = command.user();\n        if (realUser.equals(bot.repo().forge().currentUser())) {\n            var botComment = allComments.stream()\n                    .filter(comment -> comment.author().equals(bot.repo().forge().currentUser()))\n                    .filter(comment -> comment.body().contains(\"<!-- add backport \" + targetRepoName + \":\" + targetBranchName + \" -->\"))\n                    .reduce((first, second) -> second).orElse(null);\n            if (botComment != null) {\n                String[] lines = botComment.body().split(\"\\\\n\");\n                String userName = lines[lines.length - 1].split(\" \")[1];\n                var user = bot.repo().forge().user(userName);\n                if (user.isPresent()) {\n                    realUser = user.get();\n                    reply.print(\"@\");\n                    reply.print(realUser.username());\n                    reply.print(\" \");\n                } else {\n                    reply.println(\"Error: can not find the real user of Backport for repo `\" + targetRepoName + \"` on branch `\" + targetBranchName);\n                    return;\n                }\n            }\n        }\n\n        if (!targetRepo.canCreatePullRequest(realUser)) {\n            reply.println(INSUFFICIENT_ACCESS_WARNING);\n            return;\n        }\n\n        try {\n            var hash = commit.hash();\n            Hash backportHash;\n            var backportBranchName = \"backport-\" + realUser.username() + \"-\" + hash.abbreviate() + \"-\" + targetBranchName;\n            var backportBranchHash = fork.branchHash(backportBranchName);\n\n            var message = CommitMessageParsers.v1.parse(commit);\n            var formatter = DateTimeFormatter.ofPattern(\"d MMM uuuu\");\n            var body = new ArrayList<String>();\n            body.add(\"> Hi all,\");\n            body.add(\"> \");\n            body.add(\"> This pull request contains a backport of commit \" +\n                    \"[\" + hash.abbreviate() + \"](\" + commit.url() + \") from the \" +\n                    \"[\" + bot.repo().name() + \"](\" + bot.repo().webUrl() + \") repository.\");\n            body.add(\">\");\n            var info = \"> The commit being backported was authored by \" + commit.author().name() + \" on \" +\n                    commit.committed().format(formatter);\n            if (message.reviewers().isEmpty()) {\n                info += \" and had no reviewers\";\n            } else {\n                var reviewers = new ArrayList<String>();\n                for (var username : message.reviewers()) {\n                    var reviewerEntry = censusInstance.census.contributor(username);\n                    if (reviewerEntry != null) {\n                        reviewers.add(reviewerEntry.fullName().isPresent() ? reviewerEntry.fullName().get() : reviewerEntry.username());\n                    } else {\n                        reviewers.add(username);\n                    }\n                }\n                var numReviewers = reviewers.size();\n                var listing = numReviewers == 1 ?\n                        reviewers.get(0) :\n                        String.join(\", \", reviewers.subList(0, numReviewers - 1));\n                if (numReviewers > 1) {\n                    listing += \" and \" + reviewers.get(numReviewers - 1);\n                }\n                info += \" and was reviewed by \" + listing;\n            }\n            info += \".\";\n            body.add(info);\n            body.add(\"> \");\n            body.add(\"> Thanks!\");\n\n            if (backportBranchHash.isEmpty()) {\n                var localRepoDir = scratchArea.get(this)\n                        .resolve(targetRepo.name())\n                        .resolve(\"fork\");\n                var localRepo = bot.hostedRepositoryPool()\n                                   .orElseThrow(() -> new IllegalStateException(\"Missing repository pool for PR bot\"))\n                                   .materialize(targetRepo, localRepoDir);\n                var fetchHead = localRepo.fetch(bot.repo().authenticatedUrl(), hash.hex(), false).orElseThrow();\n                var head = localRepo.fetch(targetRepo.authenticatedUrl(), targetBranchName, false).orElseThrow();\n                var backportBranch = localRepo.branch(head, backportBranchName);\n                localRepo.checkout(backportBranch);\n                var didApply = localRepo.cherryPick(fetchHead);\n                if (!didApply) {\n                    var lines = new ArrayList<String>();\n                    lines.add(\"Could **not** automatically backport `\" + hash.abbreviate() + \"` to \" +\n                              \"[\" + targetRepoName + \"](\" + targetRepo.webUrl() + \") due to conflicts in the following files:\");\n                    lines.add(\"\");\n                    var unmerged = localRepo.status()\n                                            .stream()\n                                            .filter(e -> e.status().isUnmerged())\n                                            .map(e -> e.target().path().orElseGet(() -> e.source().path().orElseThrow()))\n                                            .collect(Collectors.toList());\n                    for (var path : unmerged) {\n                        lines.add(\"- \" + path.toString());\n                    }\n                    lines.add(\"\");\n                    lines.add(\"Please fetch the appropriate branch/commit and manually resolve these conflicts \"\n                            + \"by using the following commands in your personal fork of [\" + targetRepoName + \"](\" + targetRepo.webUrl()\n                            + \"). Note: these commands are just some suggestions and you can use other equivalent commands you know.\");\n                    lines.add(\"\");\n                    lines.add(\"```\");\n                    lines.add(\"# Fetch the up-to-date version of the target branch\");\n                    lines.add(\"$ git fetch --no-tags \" + targetRepo.url() + \" \" + targetBranch.name() + \":\" + targetBranch.name());\n                    lines.add(\"\");\n                    lines.add(\"# Check out the target branch and create your own branch to backport\");\n                    lines.add(\"$ git checkout \" + targetBranch.name());\n                    lines.add(\"$ git checkout -b \" + backportBranchName);\n                    lines.add(\"\");\n                    lines.add(\"# Fetch the commit you want to backport\");\n                    lines.add(\"$ git fetch --no-tags \" + bot.repo().url() + \" \" + hash.hex());\n                    lines.add(\"\");\n                    lines.add(\"# Backport the commit\");\n                    lines.add(\"$ git cherry-pick --no-commit \" + hash.hex());\n                    lines.add(\"# Resolve conflicts now\");\n                    lines.add(\"\");\n                    lines.add(\"# Commit the files you have modified\");\n                    lines.add(\"$ git add files/with/resolved/conflicts\");\n                    lines.add(\"$ git commit -m 'Backport \" + hash.hex() + \"'\");\n                    lines.add(\"```\");\n                    lines.add(\"\");\n                    lines.add(\"Once you have resolved the conflicts as explained above continue with creating a pull request towards the [\" + targetRepoName + \"](\" + targetRepo.webUrl() + \") with the title `Backport \" + hash.hex() + \"`.\");\n                    lines.add(\"\");\n                    lines.add(\"Below you can find a suggestion for the pull request body:\");\n                    lines.addAll(body);\n                    reply.println(String.join(\"\\n\", lines));\n                    localRepo.reset(head, true);\n                    return;\n                }\n                // Check that applying the change actually created a diff\n                if (localRepo.diff(head).patches().isEmpty()) {\n                    reply.println(\"Could **not** apply backport `\" + hash.abbreviate() + \"` to \" +\n                            \"[\" + targetRepoName + \"](\" + targetRepo.webUrl() + \") because the change is already present in the target.\");\n                    localRepo.reset(head, true);\n                    return;\n                }\n\n                backportHash = localRepo.commit(\"Backport \" + hash.hex(), \"duke\", \"duke@openjdk.org\");\n                localRepo.push(backportHash, fork.authenticatedUrl(), backportBranchName, false);\n            } else {\n                backportHash = backportBranchHash.get();\n            }\n\n            var invitationReminder = \"\";\n            if (!fork.canPush(realUser)) {\n                fork.addCollaborator(realUser, true);\n                if (bot.repo().forge().name().equals(\"GitHub\")) {\n                    invitationReminder = \"\\n\\n⚠️ @\" + realUser.username() +\n                            \" You are not yet a collaborator in my fork [\" + fork.name() + \"](\" + fork.url() + \").\" +\n                            \" An invite will be sent out and you need to accept it before you can proceed.\";\n                }\n            } else {\n                // It's not possible to add branch level push protection unless the user\n                // already has push permissions in the repository. If we try to restrict\n                // it anyway, nobody can push to the branch. At least this way, if the\n                // user already has push permissions, the branch will be protected,\n                // otherwise anyone with push permissions in the repository will be able\n                // to push to the branch.\n                fork.restrictPushAccess(new Branch(backportBranchName), realUser);\n            }\n\n            var createPrUrl = fork.createPullRequestUrl(targetRepo, targetBranch.name(), backportBranchName);\n            var targetBranchWebUrl = targetRepo.webUrl(targetBranch);\n            var backportBranchWebUrl = fork.webUrl(new Branch(backportBranchName));\n            var backportWebUrl = fork.webUrl(backportHash);\n            reply.println(\"the [backport](\" + backportWebUrl + \")\" +\n                          \" was successfully created on the branch [\" + backportBranchName + \"](\" +\n                          backportBranchWebUrl + \") in my [personal fork](\" + fork.webUrl() + \") of [\" +\n                          targetRepo.name() + \"](\" + targetRepo.webUrl() + \"). To create a pull request \" +\n                          \"with this backport targeting [\" + targetRepo.name() + \":\" + targetBranch.name() + \"](\" +\n                          targetBranchWebUrl + \"), just click the following link:\\n\" +\n                          \"\\n\" +\n                          \"[:arrow_right: ***Create pull request***](\" + createPrUrl + \")\\n\" +\n                          \"\\n\" +\n                          \"The title of the pull request is automatically filled in correctly and below you \" +\n                          \"find a suggestion for the pull request body:\\n\" +\n                          \"\\n\" +\n                          String.join(\"\\n\", body) +\n                          \"\\n\" +\n                          \"\\n\" +\n                          \"If you need to update the [source branch](\" + backportBranchWebUrl + \") of the pull \" +\n                          \"then run the following commands in a local clone of your personal fork of \" +\n                          \"[\" + targetRepo.name() + \"](\" + targetRepo.webUrl() + \"):\\n\" +\n                          \"\\n\" +\n                          \"```\\n\" +\n                          \"$ git fetch \" + fork.url() + \" \" + backportBranchName + \":\" + backportBranchName + \"\\n\" +\n                          \"$ git checkout \" + backportBranchName + \"\\n\" +\n                          \"# make changes\\n\" +\n                          \"$ git add paths/to/changed/files\\n\" +\n                          \"$ git commit --message 'Describe additional changes made'\\n\" +\n                          \"$ git push \" + fork.url() + \" \" + backportBranchName + \"\\n\" +\n                          \"```\" +\n                          invitationReminder);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/BranchCommand.java",
    "content": "/*\n * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.HostedBranch;\nimport org.openjdk.skara.forge.HostedCommit;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.jcheck.JCheckConfiguration;\n\nimport java.io.PrintWriter;\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.util.List;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.branch;\n\npublic class BranchCommand implements CommandHandler {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n\n    private void showHelp(PrintWriter reply) {\n        reply.println(\"Usage: `/branch <name>`\");\n    }\n\n    @Override\n    public String description() {\n        return \"create a branch\";\n    }\n\n    @Override\n    public String name() {\n        return branch.name();\n    }\n\n    @Override\n    public boolean allowedInCommit() {\n        return true;\n    }\n\n    @Override\n    public boolean allowedInPullRequest() {\n        return false;\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, HostedCommit commit, LimitedCensusInstance censusInstance,\n            ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        try {\n            if (!bot.integrators().contains(command.user().username())) {\n                reply.println(\"Only integrators for this repository are allowed to use the `/branch` command.\");\n                return;\n            }\n            if (censusInstance.contributor(command.user()).isEmpty()) {\n                printInvalidUserWarning(bot, reply);\n                return;\n            }\n\n            var args = command.args();\n            if (args.isBlank()) {\n                showHelp(reply);\n                return;\n            }\n\n            var parts = args.split(\" \");\n            if (parts.length > 1) {\n                showHelp(reply);\n                return;\n            }\n            var branchName = parts[0];\n\n            var localRepoDir = scratchArea.get(this)\n                    .resolve(bot.repo().name());\n            var localRepo = bot.hostedRepositoryPool()\n                               .orElseThrow(() -> new IllegalStateException(\"Missing repository pool for PR bot\"))\n                               .materialize(bot.repo(), localRepoDir);\n            localRepo.fetch(bot.repo().authenticatedUrl(), commit.hash().toString(), true).orElseThrow();\n\n            var remoteBranches = bot.repo().branches();\n            var remoteBranchNames = remoteBranches.stream()\n                                                  .map(HostedBranch::name)\n                                                  .collect(Collectors.toSet());\n            if (remoteBranchNames.contains(branchName)) {\n                var msg = \"A branch with name `\" + branchName + \"` already exists\";\n                var remoteBranch = remoteBranches.stream().filter(r -> r.name().equals(branchName)).findFirst();\n                if (remoteBranch.isPresent()) {\n                    var hash = remoteBranch.get().hash();\n                    var hashUrl = bot.repo().webUrl(hash);\n                    msg += \" that refers to commit [\" + hash.abbreviate() + \"](\" + hashUrl + \").\";\n                } else {\n                    msg += \" (could not find the commit it refers to).\";\n                }\n                reply.println(msg);\n                return;\n            }\n\n            var jcheckConf = JCheckConfiguration.from(localRepo, commit.hash());\n            var branchPattern = jcheckConf.map(c -> c.repository().branches());\n            if (branchPattern.isPresent() && !branchName.matches(branchPattern.get())) {\n                reply.println(\"The given branch name `\" + branchName + \"` is not of the form `\" + branchPattern.get() + \"`.\");\n                return;\n            }\n\n            var branch = localRepo.branch(commit.hash(), branchName);\n            log.info(\"Pushing branch '\" + branch + \"' to refer to commit: \" + commit.hash().hex());\n            localRepo.push(commit.hash(), bot.repo().authenticatedUrl(), branch.name(), false, false);\n            reply.println(\"The branch [\" + branch.name() + \"](\" + bot.repo().webUrl(branch) + \") was successfully created.\");\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/CSRCommand.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.bots.common.SolvesTracker;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.*;\n\nimport java.io.PrintWriter;\nimport java.util.List;\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.csr;\nimport static org.openjdk.skara.bots.common.PullRequestConstants.*;\nimport static org.openjdk.skara.bots.pr.CheckRun.CSR_PROCESS_LINK;\n\npublic class CSRCommand implements CommandHandler {\n\n    private static final Pattern CSR_PROGRESS_PATTERN = Pattern.compile(\"- \\\\[[ x]?\\\\] Change requires CSR request \\\\[(.*?)\\\\]\\\\((.*?)\\\\) to be approved\");\n    private static final Pattern RESOLVED_CSR_PROGRESS_PATTERN = Pattern.compile(\"- \\\\[x\\\\] Change requires CSR request \\\\[(.*?)\\\\]\\\\((.*?)\\\\) to be approved\");\n\n    private static void showHelp(PrintWriter writer) {\n        writer.println(\"usage: `/csr [needed|unneeded]`, requires that the issue the pull request refers to links to an approved [CSR](\" + CSR_PROCESS_LINK + \") request.\");\n    }\n\n    private static void csrReply(PrintWriter writer) {\n        writer.println(\"has indicated that a \" +\n                \"[compatibility and specification](\" + CSR_PROCESS_LINK + \") (CSR) request \" +\n                \"is needed for this pull request.\");\n        writer.println(CSR_NEEDED_MARKER);\n    }\n\n    private static void jbsReply(PullRequest pr, PrintWriter writer) {\n        writer.println(\"@\" + pr.author().username() + \" this pull request must refer to an issue in \" +\n                \"[JBS](https://bugs.openjdk.org) to be able to link it to a [CSR](\" + CSR_PROCESS_LINK + \") request. To refer this pull request to \" +\n                \"an issue in JBS, please update the title of this pull request to just the issue ID.\");\n    }\n\n    private static void multipleIssueReply(PullRequest pr, PrintWriter writer) {\n        writer.println(\"@\" + pr.author().username() + \" please create a [CSR](\" + CSR_PROCESS_LINK + \") request, \" +\n                \"with the correct fix version, for at least one of the issues associated with this pull request.\" +\n                \" This pull request cannot be integrated until all the CSR request are approved.\");\n    }\n\n    private static void singleIssueLinkReply(PullRequest pr, IssueTrackerIssue issue, PrintWriter writer) {\n        writer.println(\"@\" + pr.author().username() + \" please create a [CSR](\" + CSR_PROCESS_LINK + \") request for issue \" +\n                \"[\" + issue.id() + \"](\" + issue.webUrl() + \") with the correct fix version. \" +\n                \"This pull request cannot be integrated until the CSR request is approved.\");\n    }\n\n    private static void csrUnneededReply(PullRequest pr, PrintWriter writer) {\n        writer.println(\"determined that a [CSR](\" + CSR_PROCESS_LINK + \") request \" +\n                \"is not needed for this pull request.\");\n        writer.println(CSR_UNNEEDED_MARKER);\n        if (pr.labelNames().contains(CSR_LABEL)) {\n            pr.removeLabel(CSR_LABEL);\n        }\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        if (!bot.enableCsr()) {\n            reply.println(\"This repository has not been configured to use the `csr` command.\");\n            return;\n        }\n\n        if (!pr.author().equals(command.user()) && !censusInstance.isReviewer(command.user())) {\n            reply.println(\"only the pull request author and [Reviewers](https://openjdk.org/bylaws#reviewer) are allowed to use the `csr` command.\");\n            return;\n        }\n\n        var labels = pr.labelNames();\n\n        var cmd = command.args().trim().toLowerCase();\n        if (!cmd.isEmpty() && !(cmd.equals(\"needed\") || cmd.equals(\"unneeded\") || cmd.equals(\"uneeded\"))) {\n            showHelp(reply);\n            return;\n        }\n\n        if (cmd.equals(\"unneeded\") || cmd.equals(\"uneeded\")) {\n            if (pr.author().equals(command.user()) && !censusInstance.isReviewer(command.user())) {\n                reply.println(\"only [Reviewers](https://openjdk.org/bylaws#reviewer) can determine that a CSR is not needed.\");\n                return;\n            }\n\n            var csrs = pr.body()\n                    .lines()\n                    .map(CSR_PROGRESS_PATTERN::matcher)\n                    .filter(Matcher::matches)\n                    .toList();\n\n            // PR's body could be stale, so fetch the csr from jbs to check if the csr has been withdrawn\n            var issueProject = bot.issueProject();\n            var filteredCsrs = csrs.stream()\n                    .filter(csr -> issueProject.issue(csr.group(1))\n                            .filter(issueTrackerIssue -> !CheckRun.isWithdrawnCSR(issueTrackerIssue))\n                            .isPresent())\n                    .toList();\n\n            if (!filteredCsrs.isEmpty()) {\n                var csrLinks = new StringBuilder();\n                for (Matcher csr : filteredCsrs) {\n                    csrLinks.append(\"[\").append(csr.group(1)).append(\"](\").append(csr.group(2)).append(\")\").append(\" \");\n                }\n                reply.println(\"The CSR requirement cannot be removed as CSR issues already exist. Please withdraw \" + csrLinks +\n                        \"and then use the command `/csr unneeded` again.\");\n                reply.println(CSR_NEEDED_MARKER);\n            } else {\n                // All the issues associated with this pr either don't have csr issue or the csr issue has already been withdrawn,\n                // the bot should just remove the csr label and reply the message.\n                csrUnneededReply(pr, reply);\n            }\n            return;\n        }\n\n        if (labels.contains(CSR_LABEL)) {\n            reply.println(\"an approved [CSR](\" + CSR_PROCESS_LINK + \") request \" +\n                    \"is already required for this pull request.\");\n            reply.println(CSR_NEEDED_MARKER);\n            return;\n        }\n\n        var issueProject = bot.issueProject();\n        // Main issue is missing, this pr doesn't solve any issue\n        var mainIssue = org.openjdk.skara.vcs.openjdk.Issue.fromStringRelaxed(pr.title());\n        if (issueProject == null || mainIssue.isEmpty()) {\n            csrReply(reply);\n            jbsReply(pr, reply);\n            pr.addLabel(CSR_LABEL);\n            return;\n        }\n\n        var jbsMainIssueOpt = issueProject.issue(mainIssue.get().shortId());\n        if (jbsMainIssueOpt.isEmpty()) {\n            csrReply(reply);\n            jbsReply(pr, reply);\n            pr.addLabel(CSR_LABEL);\n            return;\n        }\n\n        var resolvedCSRs = pr.body()\n                .lines()\n                .map(RESOLVED_CSR_PROGRESS_PATTERN::matcher)\n                .filter(Matcher::matches)\n                .toList();\n\n        if (!resolvedCSRs.isEmpty()) {\n            var csrLinks = new StringBuilder();\n            for (Matcher resolvedCSR : resolvedCSRs) {\n                csrLinks.append(\"[\").append(resolvedCSR.group(1)).append(\"](\").append(resolvedCSR.group(2)).append(\")\").append(\" \");\n            }\n            reply.println(\"This pull request already associated with these approved CSRs: \" + csrLinks);\n            reply.println(CSR_NEEDED_MARKER);\n        } else {\n            csrReply(reply);\n            var issues = SolvesTracker.currentSolved(pr.repository().forge().currentUser(), allComments, pr.title());\n            if (issues.isEmpty()) {\n                singleIssueLinkReply(pr, jbsMainIssueOpt.get(), reply);\n            } else {\n                multipleIssueReply(pr, reply);\n            }\n            pr.addLabel(CSR_LABEL);\n        }\n    }\n\n    @Override\n    public String description() {\n        return \"require a compatibility and specification request (CSR) for this pull request\";\n    }\n\n    @Override\n    public String name() {\n        return csr.name();\n    }\n\n    @Override\n    public boolean allowedInBody() {\n        return true;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/CSRIssueBot.java",
    "content": "/*\n * Copyright (c) 2022, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.logging.Logger;\n\nimport org.openjdk.skara.bot.Bot;\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.issuetracker.IssueProjectPoller;\nimport org.openjdk.skara.issuetracker.IssueProject;\nimport org.openjdk.skara.issuetracker.IssueTrackerIssue;\n\n/**\n * The CSRIssueBot polls an IssueProject for updated issues of CSR type. When\n * found, IssueWorkItems are created to figure out if any PR needs to be\n * re-evaluated.\n */\npublic class CSRIssueBot implements Bot {\n    private final IssueProject issueProject;\n    private final List<HostedRepository> repositories;\n    private final IssueProjectPoller poller;\n    private final Map<String, PullRequestBot> pullRequestBotMap;\n    private final Map<String, List<PRRecord>> issuePRMap;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n\n    public CSRIssueBot(IssueProject issueProject, List<HostedRepository> repositories, Map<String, PullRequestBot> pullRequestBotMap,\n                       Map<String, List<PRRecord>> issuePRMap) {\n        this.issueProject = issueProject;\n        this.repositories = repositories;\n        this.pullRequestBotMap = pullRequestBotMap;\n        this.issuePRMap = issuePRMap;\n        // The PullRequestBot will initially evaluate all active PRs so there\n        // is no need to look at any issues older than the start time of the bot\n        // here. A padding of 10 minutes for the initial query should cover any\n        // potential time difference between local and remote, as well as timing\n        // issues between the first run of each bot, without the risk of\n        // returning excessive amounts of Issues in the first run.\n        this.poller = new IssueProjectPoller(issueProject, Duration.ofMinutes(10)) {\n            // Only query for CSR issues in this poller.\n            @Override\n            protected List<IssueTrackerIssue> queryIssues(IssueProject issueProject, ZonedDateTime updatedAfter) {\n                return issueProject.csrIssues(updatedAfter);\n            }\n        };\n    }\n\n    @Override\n    public String toString() {\n        return \"CSRIssueBot@\" + issueProject.name();\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        var issues = poller.updatedIssues();\n        log.info(\"Found \" + issues.size() + \" updated csr issues\");\n        var items = issues.stream()\n                .map(i -> (WorkItem) new CSRIssueWorkItem(this, i, e -> poller.retryIssue(i)))\n                .toList();\n        poller.lastBatchHandled();\n        return items;\n    }\n\n    @Override\n    public String name() {\n        return PullRequestBotFactory.NAME + \"-csr\";\n    }\n\n    List<HostedRepository> repositories() {\n        return repositories;\n    }\n\n    PullRequestBot getPRBot(String repo) {\n        return pullRequestBotMap.get(repo);\n    }\n\n    Map<String, List<PRRecord>> issuePRMap() {\n        return issuePRMap;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/CSRIssueWorkItem.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.List;\nimport java.util.function.Consumer;\nimport java.util.logging.Logger;\nimport java.util.stream.Stream;\n\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.issuetracker.IssueTrackerIssue;\nimport org.openjdk.skara.issuetracker.Link;\nimport org.openjdk.skara.jbs.Backports;\n\n/**\n * The CSRIssueWorkItem is read-only. Its purpose is to create PullRequestWorkItems for\n * every pull request found in the Backport hierarchy associated with a CSR issue.\n * It should only be triggered when a modified CSR issue has been found.\n */\nclass CSRIssueWorkItem implements WorkItem {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n\n    private final CSRIssueBot bot;\n    private final IssueTrackerIssue csrIssue;\n    private final Consumer<RuntimeException> errorHandler;\n\n    public CSRIssueWorkItem(CSRIssueBot bot, IssueTrackerIssue csrIssue, Consumer<RuntimeException> errorHandler) {\n        this.bot = bot;\n        this.csrIssue = csrIssue;\n        this.errorHandler = errorHandler;\n    }\n\n    @Override\n    public String toString() {\n        return botName() + \"/CSRIssueWorkItem@\" + csrIssue.id();\n    }\n\n    /**\n     * Concurrency between CSRIssueWorkItems is ok as long as they aren't processing the\n     * same issue and are spawned from the same bot instance.\n     */\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof CSRIssueWorkItem otherItem)) {\n            return true;\n        }\n\n        if (!csrIssue.project().name().equals(otherItem.csrIssue.project().name())) {\n            return true;\n        }\n\n        if (!csrIssue.id().equals(otherItem.csrIssue.id())) {\n            return true;\n        }\n\n        if (!bot.equals(otherItem.bot)) {\n            return true;\n        }\n        return false;\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        var link = csrIssue.links().stream()\n                .filter(l -> l.relationship().isPresent() && \"csr of\".equals(l.relationship().get())).findAny();\n        var issue = link.flatMap(Link::issue);\n        var mainIssue = issue.flatMap(Backports::findMainIssue);\n        if (mainIssue.isEmpty()) {\n            return List.of();\n        }\n        var backports = Backports.findBackports(mainIssue.get(), false);\n        var ret = new ArrayList<WorkItem>();\n        Stream.concat(mainIssue.stream(), backports.stream())\n                // Get all pull request ids related with all the issues\n                .flatMap(i -> bot.issuePRMap().get(i.id()) == null ? Stream.of() : bot.issuePRMap().get(i.id()).stream())\n                // Get all the pull requests\n                .flatMap(record -> bot.repositories().stream()\n                        .filter(r -> r.name().equals(record.repoName()))\n                        .map(r -> r.pullRequest(record.prId()))\n                )\n                .filter(Issue::isOpen)\n                .filter(pr -> bot.getPRBot(pr.repository().name()).enableCsr())\n                // This will mix time stamps from the IssueTracker and the Forge hosting PRs, but it's the\n                // best we can do.\n                .map(pr -> CheckWorkItem.fromCSRIssue(bot.getPRBot(pr.repository().name()), pr.id(), errorHandler, csrIssue.updatedAt(),\n                        !bot.issuePRMap().containsKey(csrIssue.id()) ||\n                                bot.issuePRMap().get(csrIssue.id()).stream().noneMatch(prRecord -> prRecord.prId().equals(pr.id()))))\n                .forEach(ret::add);\n        return ret;\n    }\n\n    @Override\n    public String botName() {\n        return bot.name();\n    }\n\n    @Override\n    public String workItemName() {\n        return \"issue\";\n    }\n\n    @Override\n    public void handleRuntimeException(RuntimeException e) {\n        errorHandler.accept(e);\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/CensusInstance.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.census.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.jcheck.JCheckConfiguration;\n\nimport java.nio.file.Path;\nimport java.util.Optional;\n\nclass CensusInstance extends LimitedCensusInstance {\n    private final Project project;\n\n    private CensusInstance(Census census, JCheckConfiguration configuration, Project project, Namespace namespace) {\n        super(census, configuration, namespace);\n        this.project = project;\n    }\n\n    private static Project project(JCheckConfiguration configuration, Census census) {\n        var project = census.project(configuration.general().project());\n\n        if (project == null) {\n            throw new RuntimeException(\"Project not found in census: \" + configuration.general().project());\n        }\n\n        return project;\n    }\n\n    static CensusInstance createCensusInstance(HostedRepositoryPool hostedRepositoryPool,\n                                 HostedRepository censusRepo, String censusRef, Path folder, PullRequest pr,\n                                 HostedRepository confOverrideRepo, String confOverrideName, String confOverrideRef) throws InvalidJCheckConfException, MissingJCheckConfException {\n        return createCensusInstance(hostedRepositoryPool, censusRepo, censusRef, folder, pr.repository(), pr.targetRef(),\n                      confOverrideRepo, confOverrideName, confOverrideRef);\n    }\n\n    static CensusInstance createCensusInstance(HostedRepositoryPool hostedRepositoryPool,\n                                 HostedRepository censusRepo, String censusRef, Path folder, HostedRepository repository, String ref,\n                                 HostedRepository confOverrideRepo, String confOverrideName, String confOverrideRef) throws InvalidJCheckConfException, MissingJCheckConfException {\n        var limitedCensusInstance = LimitedCensusInstance.createLimitedCensusInstance(hostedRepositoryPool, censusRepo,\n                censusRef, folder, repository, ref, confOverrideRepo, confOverrideName, confOverrideRef);\n        return new CensusInstance(limitedCensusInstance.census, limitedCensusInstance.configuration,\n                project(limitedCensusInstance.configuration, limitedCensusInstance.census), limitedCensusInstance.namespace);\n    }\n\n    Project project() {\n        return project;\n    }\n\n    boolean isAuthor(HostUser hostUser) {\n        int version = census.version().format();\n        var contributor = namespace.get(hostUser.id());\n        if (contributor == null) {\n            return false;\n        }\n        return project.isAuthor(contributor.username(), version);\n    }\n\n    boolean isCommitter(HostUser hostUser) {\n        int version = census.version().format();\n        var contributor = namespace.get(hostUser.id());\n        if (contributor == null) {\n            return false;\n        }\n        return project.isCommitter(contributor.username(), version);\n    }\n\n    boolean isReviewer(HostUser hostUser) {\n        int version = census.version().format();\n        var contributor = namespace.get(hostUser.id());\n        if (contributor == null) {\n            return false;\n        }\n        return project.isReviewer(contributor.username(), version);\n    }\n}\n\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/CheckRun.java",
    "content": "/*\n * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.bots.common.BotUtils;\nimport org.openjdk.skara.bots.common.SolvesTracker;\nimport org.openjdk.skara.census.Contributor;\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.jbs.Backports;\nimport org.openjdk.skara.jbs.JdkVersion;\nimport org.openjdk.skara.jcheck.JCheckConfiguration;\nimport org.openjdk.skara.jcheck.TooFewReviewersIssue;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.Issue;\n\nimport java.io.*;\nimport java.nio.file.Path;\nimport java.text.Normalizer;\nimport java.time.*;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.regex.Matcher;\nimport java.util.stream.*;\n\nimport static org.openjdk.skara.bots.common.PullRequestConstants.*;\nimport static org.openjdk.skara.bots.pr.LabelerWorkItem.INITIAL_LABEL_MESSAGE;\n\nclass CheckRun {\n    public static final String MSG_EMPTY_BODY = \"The pull request body must not be empty.\";\n\n    private final CheckWorkItem workItem;\n    private final PullRequest pr;\n    private final Repository localRepo;\n    private final List<Comment> comments;\n    private final List<Review> allReviews;\n    private final List<Review> activeReviews;\n    private final Set<String> labels;\n    private final CensusInstance censusInstance;\n    private final boolean useStaleReviews;\n    private final Set<String> integrators;\n\n    private final Hash baseHash;\n    private final CheckablePullRequest checkablePullRequest;\n\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n    protected static final String MERGE_READY_MARKER = \"<!-- PullRequestBot merge is ready comment -->\";\n    protected static final String PLACEHOLDER_MARKER = \"<!-- PullRequestBot placeholder -->\";\n    private static final String OUTDATED_HELP_MARKER = \"<!-- PullRequestBot outdated help comment -->\";\n    private static final String SOURCE_BRANCH_WARNING_MARKER = \"<!-- PullRequestBot source branch warning comment -->\";\n    private static final String MERGE_COMMIT_WARNING_MARKER = \"<!-- PullRequestBot merge commit warning comment -->\";\n    private static final String EMPTY_PR_BODY_MARKER = \"<!--\\nReplace this text with a description of your pull request (also remove the surrounding HTML comment markers).\\n\" +\n            \"If in doubt, feel free to delete everything in this edit box first, the bot will restore the progress section as needed.\\n-->\";\n    private static final String FULL_NAME_WARNING_MARKER = \"<!-- PullRequestBot full name warning comment -->\";\n    private static final String DIFF_TOO_LARGE_WARNING_MARKER = \"<!-- PullRequestBot diff too large warning comment -->\";\n    private static final String APPROVAL_NEEDED_MARKER = \"<!-- PullRequestBot approval needed comment -->\";\n    private static final String BACKPORT_CSR_MARKER = \"<!-- PullRequestBot backport csr comment -->\";\n    private static final Set<String> PRIMARY_TYPES = Set.of(\"Bug\", \"New Feature\", \"Enhancement\", \"Task\", \"Sub-task\");\n    protected static final String CSR_PROCESS_LINK = \"https://wiki.openjdk.org/display/csr/Main\";\n    private static final Path JCHECK_CONF_PATH = Path.of(\".jcheck\", \"conf\");\n    private static final int MESSAGE_LIMIT = 50;\n    private final Set<String> newLabels;\n    private final boolean reviewCleanBackport;\n    private final List<String> requiredCheckedLines;\n    private final Approval approval;\n    private final boolean reviewersCommandIssuedByUser;\n    private final ReviewCoverage reviewCoverage;\n\n    private Duration expiresIn;\n    // Only set if approval is configured for the repo\n    private String realTargetRef;\n    private boolean missingApprovalRequest = false;\n    private boolean rfrPendingOnOtherWorkItems = false;\n\n    private CheckRun(CheckWorkItem workItem, PullRequest pr, Repository localRepo, List<Comment> comments,\n                     List<Review> allReviews, List<Review> activeReviews, Set<String> labels,\n                     CensusInstance censusInstance, boolean useStaleReviews, Set<String> integrators, boolean reviewCleanBackport,\n                     MergePullRequestReviewConfiguration reviewMerge, Approval approval, List<String> requiredCheckedLines) throws IOException {\n        this.workItem = workItem;\n        this.pr = pr;\n        this.localRepo = localRepo;\n        this.comments = comments;\n        this.allReviews = allReviews;\n        this.activeReviews = activeReviews;\n        this.labels = new HashSet<>(labels);\n        this.newLabels = new HashSet<>(labels);\n        this.censusInstance = censusInstance;\n        this.useStaleReviews = useStaleReviews;\n        this.integrators = integrators;\n        this.reviewCleanBackport = reviewCleanBackport;\n        this.approval = approval;\n        this.requiredCheckedLines = requiredCheckedLines;\n        var additionalRequiredReviewers = ReviewersTracker.additionalRequiredReviewers(pr.repository().forge().currentUser(), comments);\n        this.reviewersCommandIssuedByUser = additionalRequiredReviewers.isPresent()\n                && additionalRequiredReviewers.get().source() == ReviewersTracker.Source.USER;\n\n        // If reviewers command is issued, enable reviewers check for merge pull requests\n        if (reviewersCommandIssuedByUser) {\n            reviewMerge = MergePullRequestReviewConfiguration.ALWAYS;\n        }\n\n        reviewCoverage = new ReviewCoverage(workItem.bot.useStaleReviews(), workItem.bot.acceptSimpleMerges(), localRepo, pr);\n        baseHash = PullRequestUtils.baseHash(pr, localRepo);\n        checkablePullRequest = new CheckablePullRequest(pr, localRepo, useStaleReviews,\n                workItem.bot.confOverrideRepository().orElse(null),\n                workItem.bot.confOverrideName(),\n                workItem.bot.confOverrideRef(),\n                comments,\n                reviewMerge,\n                reviewCoverage);\n    }\n\n    static Optional<Instant> execute(CheckWorkItem workItem, PullRequest pr, Repository localRepo, List<Comment> comments,\n                                     List<Review> allReviews, List<Review> activeReviews, Set<String> labels, CensusInstance censusInstance,\n                                     boolean useStaleReviews, Set<String> integrators, boolean reviewCleanBackport, MergePullRequestReviewConfiguration reviewMerge,\n                                     Approval approval, List<String> requiredCheckedLines) throws IOException {\n        var run = new CheckRun(workItem, pr, localRepo, comments, allReviews, activeReviews, labels, censusInstance,\n                useStaleReviews, integrators, reviewCleanBackport, reviewMerge, approval, requiredCheckedLines);\n        run.checkStatus();\n        if (run.expiresIn != null) {\n            return Optional.of(Instant.now().plus(run.expiresIn));\n        } else {\n            return Optional.empty();\n        }\n    }\n\n    private boolean isTargetBranchAllowed() {\n        if (PreIntegrations.isPreintegrationBranch(pr.targetRef())) {\n            return true;\n        }\n        var matcher = workItem.bot.allowedTargetBranches().matcher(pr.targetRef());\n        return matcher.matches();\n    }\n\n    /**\n     * Builds a map of all associated regular issues, from Issue to IssueTrackerIssue\n     * if found. The map is ordered to support consistent presentation order.\n     */\n    private Map<Issue, Optional<IssueTrackerIssue>> regularIssuesMap() {\n        var issue = Issue.fromStringRelaxed(pr.title());\n        if (issue.isPresent()) {\n            var issues = new ArrayList<Issue>();\n            issues.add(issue.get());\n            issues.addAll(SolvesTracker.currentSolved(pr.repository().forge().currentUser(), comments, pr.title()));\n            var map = new LinkedHashMap<Issue, Optional<IssueTrackerIssue>>();\n            if (issueProject() != null) {\n                issues.forEach(i -> {\n                    var issueTrackerIssue = workItem.issueTrackerIssue(i.shortId());\n                    if (issueTrackerIssue.isEmpty()) {\n                        log.info(\"Failed to retrieve issue \" + i.id());\n                        setExpiration(Duration.ofMinutes(10));\n                    }\n                    map.put(i, issueTrackerIssue);\n                });\n            } else {\n                issues.forEach(i -> {\n                    map.put(i, Optional.empty());\n                });\n            }\n            return map;\n        }\n        return Map.of();\n    }\n\n    /**\n     * Constructs a map from main issue ID to CSR issue.\n     */\n    private Map<String, IssueTrackerIssue> issueToCsrMap(Map<String, List<IssueTrackerIssue>> issueToAllCsrsMap, JdkVersion version) {\n        var csrIssueMap = new HashMap<String, IssueTrackerIssue>();\n        if (version == null) {\n            return Map.of();\n        }\n        for (var entry : issueToAllCsrsMap.entrySet()) {\n            var csrList = entry.getValue();\n            Backports.findClosestIssue(csrList, version).ifPresent(csr -> csrIssueMap.put(entry.getKey(), csr));\n        }\n        return csrIssueMap;\n    }\n\n    /**\n     * Gets the JEP issue from the IssueProject if there is one\n     */\n    private Optional<IssueTrackerIssue> jepIssue() {\n        if (issueProject() != null) {\n            var comment = findJepComment();\n            return comment.flatMap(c -> workItem.issueTrackerIssue(new Issue(c.group(2), \"\").shortId()));\n        }\n        return Optional.empty();\n    }\n\n    private Optional<Matcher> findJepComment() {\n        var jepComment = comments.stream()\n                .filter(comment -> comment.author().equals(pr.repository().forge().currentUser()))\n                .flatMap(comment -> comment.body().lines())\n                .map(JEP_MARKER_PATTERN::matcher)\n                .filter(Matcher::find)\n                .reduce((first, second) -> second);\n        if (jepComment.isPresent()) {\n            var issueId = jepComment.get().group(2);\n            if (\"unneeded\".equals(issueId)) {\n                return Optional.empty();\n            }\n        }\n        return jepComment;\n    }\n\n    private IssueProject issueProject() {\n        return workItem.bot.issueProject();\n    }\n\n    private List<String> allowedTargetBranches() {\n        return pr.repository()\n                 .branches()\n                 .stream()\n                 .map(HostedBranch::name)\n                 .filter(name -> !PreIntegrations.isPreintegrationBranch(name))\n                 .map(name -> workItem.bot.allowedTargetBranches().matcher(name))\n                 .filter(Matcher::matches)\n                 .map(Matcher::group)\n                 .collect(Collectors.toList());\n    }\n\n    private static boolean containsCheckedRequiredLine(String body, String requiredLine) {\n        // Filter out lines in the body that are inside HTML block comments and\n        // also filter out lines containing HTML comments\n        var outsideBlockComments = new ArrayList<String>();\n        var isInOpenComment = false;\n        for (var line : body.lines().toList()) {\n            var closeCommentIndex = line.indexOf(\"-->\");\n            isInOpenComment = isInOpenComment && closeCommentIndex == -1;\n\n            var outsideStartIndex = closeCommentIndex == -1 ? 0 : closeCommentIndex + \"-->\".length();\n            var outside = line.substring(outsideStartIndex);\n\n            var lastOpenCommentStartIndex = outside.lastIndexOf(\"<!--\");\n            if (lastOpenCommentStartIndex != -1) {\n                if (outside.indexOf(\"-->\", lastOpenCommentStartIndex) == -1) {\n                    isInOpenComment = true;\n                }\n            }\n\n            if (!isInOpenComment && closeCommentIndex == -1 && lastOpenCommentStartIndex == -1) {\n                outsideBlockComments.add(line);\n            }\n        }\n\n        // Check that the required line is present and checked\n        var dashLowercaseCheched = \"- [x] \" + requiredLine;\n        var dashUppercaseCheched = \"- [X] \" + requiredLine;\n        return outsideBlockComments.stream()\n            .map(String::stripTrailing)\n            .filter(l -> l.equals(dashLowercaseCheched) || l.equals(dashUppercaseCheched))\n            .count() > 0;\n    }\n\n    // Additional bot-specific checks that are not handled by JCheck\n    private List<String> botSpecificChecks(boolean isCleanBackport) {\n        var ret = new ArrayList<String>();\n\n        var bodyWithoutStatus = bodyWithoutStatus();\n        if ((bodyWithoutStatus.isBlank() || bodyWithoutStatus.equals(EMPTY_PR_BODY_MARKER)) && !isCleanBackport) {\n            ret.add(MSG_EMPTY_BODY);\n        }\n\n        for (var line : requiredCheckedLines) {\n            if (!containsCheckedRequiredLine(bodyWithoutStatus, line)) {\n                ret.add(\"Pull request body is missing required line: `- [x] \" + line + \"`\");\n            }\n        }\n\n        if (!isTargetBranchAllowed()) {\n            var error = \"The branch `\" + pr.targetRef() + \"` is not allowed as target branch. The allowed target branches are:\\n\" +\n                    allowedTargetBranches().stream()\n                    .map(name -> \"   - \" + name)\n                    .collect(Collectors.joining(\"\\n\"));\n            ret.add(error);\n        }\n\n        for (var blocker : workItem.bot.blockingCheckLabels().entrySet()) {\n            if (labels.contains(blocker.getKey())) {\n                ret.add(blocker.getValue());\n            }\n        }\n\n        if (!integrators.isEmpty() && PullRequestUtils.isMerge(pr) && !integrators.contains(pr.author().username())) {\n            var error = \"Only the designated integrators for this repository are allowed to create merge-style pull requests.\";\n            ret.add(error);\n        }\n\n        // If the bot has label configuration\n        if (!workItem.bot.labelConfiguration().allowed().isEmpty()) {\n            // If the pr is already auto labelled, check if the pull request is associated with at least one component\n            if (findComment(INITIAL_LABEL_MESSAGE).isPresent()) {\n                var existingAllowed = new HashSet<>(pr.labelNames());\n                existingAllowed.retainAll(workItem.bot.labelConfiguration().allowed());\n                if (existingAllowed.isEmpty()) {\n                    ret.add(\"This pull request must be associated with at least one component. \" +\n                            \"Please use the [/label](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands#PullRequestCommands-/label)\" +\n                            \" pull request command.\");\n                }\n            } else {\n                rfrPendingOnOtherWorkItems = true;\n            }\n        }\n\n        return ret;\n    }\n\n    public static boolean isWithdrawnCSR(IssueTrackerIssue csr) {\n        if (csr.isClosed()) {\n            var resolution = csr.resolution();\n            if (resolution.isPresent()) {\n                var name = resolution.get();\n                if (name.equals(\"Withdrawn\")) {\n                    return true;\n                }\n            }\n        }\n        return false;\n    }\n\n    private String generateCSRProgressMessage(IssueTrackerIssue issue) {\n        return \"Change requires CSR request [\" + issue.id() + \"](\" + issue.webUrl() + \") to be approved\";\n    }\n\n    // Additional bot-specific progresses that are not handled by JCheck\n    private Map<String, Boolean> botSpecificProgresses(Map<Issue, Optional<IssueTrackerIssue>> regularIssuesMap,\n                                                       List<IssueTrackerIssue> csrIssueTrackerIssues,\n                                                       IssueTrackerIssue jepIssue, JdkVersion version) {\n        var ret = new HashMap<String, Boolean>();\n\n        if (approvalNeeded()) {\n            for (var issueOpt : regularIssuesMap.values()) {\n                if (issueOpt.isPresent()) {\n                    var issue = issueOpt.get();\n                    var labelNames = issue.labelNames();\n                    if (labelNames.contains(approval.approvedLabel(pr.targetRef()))) {\n                        ret.put(\"[\" + issue.id() + \"](\" + issue.webUrl() + \") needs \" + approval.approvalTerm(), true);\n                    } else {\n                        ret.put(\"[\" + issue.id() + \"](\" + issue.webUrl() + \") needs \" + approval.approvalTerm(), false);\n                    }\n                }\n            }\n        }\n\n        var csrIssues = csrIssueTrackerIssues.stream()\n                .filter(issue -> issue.properties().containsKey(\"issuetype\"))\n                .filter(issue -> issue.properties().get(\"issuetype\").asString().equals(\"CSR\"))\n                .filter(issue -> !isWithdrawnCSR(issue))\n                .toList();\n        if (csrIssues.isEmpty() && newLabels.contains(\"csr\")) {\n            ret.put(\"Change requires a CSR request matching fixVersion \" + (version != null ? version.raw() : \"(No fixVersion in .jcheck/conf)\")\n                    + \" to be approved (needs to be created)\", false);\n        }\n        for (var csrIssue : csrIssues) {\n            if (!csrIssue.isClosed()) {\n                ret.put(generateCSRProgressMessage(csrIssue), false);\n                continue;\n            }\n            var resolution = csrIssue.resolution();\n            if (resolution.isEmpty()) {\n                ret.put(generateCSRProgressMessage(csrIssue), false);\n                continue;\n            }\n            if (!resolution.get().equals(\"Approved\")) {\n                ret.put(generateCSRProgressMessage(csrIssue), false);\n                continue;\n            }\n            ret.put(generateCSRProgressMessage(csrIssue), true);\n        }\n\n        if (jepIssue != null) {\n            var jepIssueStatus = jepIssue.status();\n            var jepResolution = jepIssue.resolution();\n            var jepHasTargeted = \"Targeted\".equals(jepIssueStatus) ||\n                    \"Integrated\".equals(jepIssueStatus) ||\n                    \"Completed\".equals(jepIssueStatus) ||\n                    (\"Closed\".equals(jepIssueStatus) && jepResolution.isPresent() && \"Delivered\".equals(jepResolution.get()));\n            ret.put(\"Change requires a JEP request to be targeted\", jepHasTargeted);\n            if (jepHasTargeted && newLabels.contains(\"jep\")) {\n                log.info(\"JEP issue \" + jepIssue.id() + \" found in state \" + jepIssueStatus + \", removing JEP label from \" + describe(pr));\n                newLabels.remove(JEP_LABEL);\n            } else if (!jepHasTargeted && !newLabels.contains(\"jep\")) {\n                log.info(\"JEP issue \" + jepIssue.id() + \" found in state \" + jepIssueStatus + \", adding JEP label to \" + describe(pr));\n                newLabels.add(JEP_LABEL);\n            }\n        }\n        return ret;\n    }\n\n    private void setExpiration(Duration expiresIn) {\n        // Use the shortest expiration\n        if (this.expiresIn == null || this.expiresIn.compareTo(expiresIn) > 0) {\n            this.expiresIn = expiresIn;\n        }\n    }\n\n    private Map<String, String> blockingIntegrationLabels() {\n        return Map.of(\"rejected\", \"The change is currently blocked from integration by a rejection.\");\n    }\n\n    private List<String> botSpecificIntegrationBlockers(Map<Issue, Optional<IssueTrackerIssue>> issues) {\n        var ret = new ArrayList<String>();\n\n        if (issueProject() != null) {\n            for (var issueEntry : issues.entrySet()) {\n                var issue = issueEntry.getKey();\n                var issueTrackerIssue = issueEntry.getValue();\n                try {\n                    if (issueTrackerIssue.isPresent()) {\n                        if (!relaxedEquals(issueTrackerIssue.get().title(), issue.description())) {\n                            var issueString = \"[\" + issueTrackerIssue.get().id() + \"](\" + issueTrackerIssue.get().webUrl() + \")\";\n                            ret.add(\"Title mismatch between PR and JBS for issue \" + issueString);\n                            setExpiration(Duration.ofMinutes(10));\n                        }\n\n                        var properties = issueTrackerIssue.get().properties();\n                        if (!properties.containsKey(\"issuetype\")) {\n                            var issueString = \"[\" + issueTrackerIssue.get().id() + \"](\" + issueTrackerIssue.get().webUrl() + \")\";\n                            ret.add(\"Issue \" + issueString + \" does not contain property `issuetype`\");\n                            setExpiration(Duration.ofMinutes(10));\n                        } else {\n                            var issueType = properties.get(\"issuetype\").asString();\n                            if (!PRIMARY_TYPES.contains(issueType)) {\n                                ret.add(\"Issue of type `\" + issueType + \"` is not allowed for integrations\");\n                                setExpiration(Duration.ofMinutes(10));\n                            }\n                        }\n                    } else {\n                        ret.add(\"Failed to retrieve information on issue `\" + issue.id() +\n                                \"`. Please make sure it exists and is accessible.\");\n                        setExpiration(Duration.ofMinutes(10));\n                    }\n                } catch (RuntimeException e) {\n                    ret.add(\"Failed to retrieve information on issue `\" + issue.id() +\n                            \"`. This may be a temporary failure and will be retried.\");\n                    setExpiration(Duration.ofMinutes(30));\n                }\n            }\n        }\n\n        labels.stream()\n              .filter(l -> blockingIntegrationLabels().containsKey(l))\n              .forEach(l -> ret.add(blockingIntegrationLabels().get(l)));\n\n        var dep = PreIntegrations.dependentPullRequestId(pr);\n        dep.ifPresent(s -> ret.add(\"Dependency #\" + s + \" must be integrated first\"));\n\n        return ret;\n    }\n\n    private void updateCheckBuilder(CheckBuilder checkBuilder, PullRequestCheckIssueVisitor visitor, List<String> additionalErrors) {\n        if (visitor.isReadyForReview() && additionalErrors.isEmpty()) {\n            checkBuilder.complete(true);\n            // It means some jchecks failed as warnings\n            if (!visitor.getAnnotations().isEmpty()) {\n                checkBuilder.title(\"Optional\");\n                checkBuilder.summary(\"These warnings will not block integration.\");\n                for (var annotation : visitor.getAnnotations()) {\n                    checkBuilder.annotation(annotation);\n                }\n            }\n        } else {\n            checkBuilder.title(\"Required\");\n            var summary = Stream.concat(visitor.errorFailedChecksMessages().stream().limit(MESSAGE_LIMIT), additionalErrors.stream().limit(MESSAGE_LIMIT))\n                    .sorted()\n                    .map(m -> \"- \" + m)\n                    .collect(Collectors.joining(\"\\n\"));\n            if (visitor.errorFailedChecksMessages().size() > MESSAGE_LIMIT || additionalErrors.size() > MESSAGE_LIMIT) {\n                summary = summary + \"\\nThere are more errors that are not displayed due to the size limit.\";\n            }\n            checkBuilder.summary(summary);\n            for (var annotation : visitor.getAnnotations()) {\n                checkBuilder.annotation(annotation);\n            }\n            checkBuilder.complete(false);\n        }\n    }\n\n    private boolean updateReadyForReview(PullRequestCheckIssueVisitor visitor, List<String> additionalErrors, Map<Issue, Optional<IssueTrackerIssue>> regularIssuesMap) {\n        // All the issues must be accessible\n        if (issueProject() != null && regularIssuesMap.values().stream().anyMatch(Optional::isEmpty)) {\n            return false;\n        }\n\n        // Additional errors are not allowed\n        if (!additionalErrors.isEmpty()) {\n            newLabels.remove(\"rfr\");\n            return false;\n        }\n\n        // Draft requests are not for review\n        if (pr.isDraft()) {\n            newLabels.remove(\"rfr\");\n            return false;\n        }\n\n        // Check if the visitor found any issues that should be resolved before reviewing\n        if (!visitor.isReadyForReview()) {\n            newLabels.remove(\"rfr\");\n            return false;\n        }\n\n        // If rfr is still pending on other workItems, so don't actively mark this pr as rfr, wait for another round of CheckWorkItem\n        if (rfrPendingOnOtherWorkItems) {\n            log.info(\"rfr is pending on other workItems for pr: \" + pr.id());\n            return newLabels.contains(\"rfr\");\n        }\n\n        // No issues found, add rfr label now\n        newLabels.add(\"rfr\");\n        return true;\n    }\n\n    private boolean updateClean(Commit commit) {\n        var backportDiff = commit.parentDiffs().get(0);\n        var prDiff = pr.diff();\n        if (!backportDiff.complete() || !prDiff.complete()) {\n            // Add diff too large warning comment\n            addDiffTooLargeWarning();\n            return false;\n        }\n        var isClean = DiffComparator.areFuzzyEqual(backportDiff, prDiff);\n        var hasCleanLabel = labels.contains(\"clean\");\n        if (isClean && !hasCleanLabel) {\n            log.info(\"Adding label clean\");\n            pr.addLabel(\"clean\");\n        }\n\n        var botUser = pr.repository().forge().currentUser();\n        var isCleanLabelManuallyAdded = comments\n                .stream()\n                .filter(c -> c.author().equals(botUser))\n                .anyMatch(c -> c.body().contains(\"This backport pull request is now marked as clean\"));\n\n        if (!isCleanLabelManuallyAdded && !isClean && hasCleanLabel) {\n            log.info(\"Removing label clean\");\n            pr.removeLabel(\"clean\");\n        }\n\n        return isClean || isCleanLabelManuallyAdded;\n    }\n\n    private void updateMergeClean(Commit commit) {\n        boolean isClean = !commit.isMerge() || localRepo.isEmptyCommit(commit.hash());\n        if (isClean) {\n            newLabels.add(\"clean\");\n        } else {\n            newLabels.remove(\"clean\");\n        }\n    }\n\n    private Optional<HostedCommit> backportedFrom() {\n        var hash = checkablePullRequest.findOriginalBackportHash();\n        if (hash == null) {\n            return Optional.empty();\n        }\n        var repoName = checkablePullRequest.findOriginalBackportRepo();\n        if (repoName == null) {\n            repoName = pr.repository().forge().search(hash).orElseThrow();\n        }\n        var repo = pr.repository().forge().repository(repoName);\n        if (repo.isEmpty()) {\n            throw new IllegalStateException(\"Backport comment for PR \" + pr.id() + \" contains bad repo name: \" + repoName);\n        }\n        var commit = repo.get().commit(hash, true);\n        if (commit.isEmpty()) {\n            throw new IllegalStateException(\"Backport comment for PR \" + pr.id() + \" contains bad hash: \" + hash.hex());\n        }\n        return commit;\n    }\n\n    private String getRole(String username) {\n        var project = censusInstance.project();\n        var version = censusInstance.census().version().format();\n        if (project.isReviewer(username, version)) {\n            return \"**Reviewer**\";\n        } else if (project.isCommitter(username, version)) {\n            return \"Committer\";\n        } else if (project.isAuthor(username, version)) {\n            return \"Author\";\n        } else {\n            return \"no project role\";\n        }\n    }\n\n    private String formatReviewer(HostUser reviewer) {\n        var contributor = censusInstance.namespace().get(reviewer.id());\n        return formatUser(reviewer, contributor);\n    }\n\n    /**\n     * Format the contributor user information.\n     * If both the HostUser and the Contributor are not null, return `[FullName](Link) (@user - RoleName)`\n     * If the HostUser is not null and the Contributor is null, return `@user (Unknown ProjectName username and role)`\n     * If the HostUser is null and the Contributor is not null, return `[FullName](Link) - RoleName` or FullName - RoleName\n     * If both the HostUser and the Contributor are null, return: null string\n     */\n    private String formatUser(HostUser user, Contributor contributor) {\n        if (contributor == null && user == null) {\n            return \"\";\n        }\n        var ret = new StringBuilder();\n        if (contributor != null && user != null) {\n            // Both the HostUser and the Contributor are not null\n            ret.append(contributorLink(contributor));\n            ret.append(\" (@\");\n            ret.append(user.username());\n            ret.append(\" - \");\n            ret.append(getRole(contributor.username()));\n            ret.append(\")\");\n            return ret.toString();\n        } else if (contributor == null) {\n            // The HostUser is not null and the Contributor is null\n            ret.append(\"@\");\n            ret.append(user.username());\n            ret.append(\" (no known \");\n            ret.append(censusInstance.configuration().census().domain());\n            ret.append(\" user name / role)\");\n        } else {\n            // The HostUser is null and the Contributor is not null\n            ret.append(contributorLink(contributor));\n            ret.append(\" - \");\n            ret.append(getRole(contributor.username()));\n        }\n        return ret.toString();\n    }\n\n    private String contributorLink(Contributor contributor) {\n        var ret = new StringBuilder();\n        var censusLink = workItem.bot.censusLink(contributor);\n        if (censusLink.isPresent()) {\n            ret.append(\"[\");\n        }\n        ret.append(contributor.fullName().orElse(contributor.username()));\n        if (censusLink.isPresent()) {\n            ret.append(\"](\");\n            ret.append(censusLink.get());\n            ret.append(\")\");\n        }\n        return ret.toString();\n    }\n\n    private String getChecksList(PullRequestCheckIssueVisitor visitor, boolean reviewNeeded, Map<String, Boolean> additionalProgresses) {\n        var checks = reviewNeeded ? visitor.getChecks() : visitor.getReadyForReviewChecks();\n        checks.putAll(additionalProgresses);\n        return checks.entrySet().stream()\n                .map(entry -> \"- [\" + (entry.getValue() ? \"x\" : \" \") + \"] \" + entry.getKey())\n                .collect(Collectors.joining(\"\\n\"));\n    }\n\n    private String warningListToText(List<String> additionalErrors) {\n        var text = additionalErrors.stream()\n                .sorted()\n                .limit(MESSAGE_LIMIT)\n                .map(err -> \"&nbsp;⚠️ \" + err)\n                .collect(Collectors.joining(\"\\n\"));\n        if (additionalErrors.size() > MESSAGE_LIMIT) {\n            text = text + \"\\n...\";\n        }\n        return text;\n    }\n\n    private Optional<String> getReviewersList(List<Review> reviews, boolean tooFewReviewers) {\n        var reviewers = reviews.stream()\n                .filter(review -> review.verdict() == Review.Verdict.APPROVED)\n                .map(review -> {\n                    var entry = \" * \" + formatReviewer(review.reviewer());\n                    if (!review.targetRef().equals(pr.targetRef())) {\n                        if (useStaleReviews || tooFewReviewers) {\n                            entry += \" 🔄 Re-review required (review was made when pull request targeted the [\" + review.targetRef()\n                                    + \"](\" + pr.repository().webUrl(new Branch(review.targetRef())) + \") branch)\";\n                        } else {\n                            entry += \" Review was made when pull request targeted the [\" + review.targetRef()\n                                    + \"](\" + pr.repository().webUrl(new Branch(review.targetRef())) + \") branch\";\n                        }\n                    } else {\n                        var hash = review.hash();\n                        if (hash.isPresent()) {\n                            if (!hash.get().equals(pr.headHash())) {\n                                if (useStaleReviews) {\n                                    entry += \" ⚠️ Review applies to [\" + hash.get().abbreviate()\n                                            + \"](\" + pr.filesUrl(hash.get()) + \")\";\n                                } else if (!reviewCoverage.covers(review) && tooFewReviewers) {\n                                    entry += \" 🔄 Re-review required (review applies to [\" + hash.get().abbreviate()\n                                            + \"](\" + pr.filesUrl(hash.get()) + \"))\";\n                                } else {\n                                    entry += \" Review applies to [\" + hash.get().abbreviate()\n                                            + \"](\" + pr.filesUrl(hash.get()) + \")\";\n                                }\n                            }\n                        } else {\n                            if (useStaleReviews || tooFewReviewers) {\n                                entry += \" 🔄 Re-review required (review applies to a commit that is no longer present)\";\n                            } else {\n                                entry += \" Review applies to a commit that is no longer present\";\n                            }\n                        }\n                    }\n                    return entry;\n                })\n                .collect(Collectors.joining(\"\\n\"));\n\n        // Check for manually added reviewers\n        if (useStaleReviews) {\n            var namespace = censusInstance.namespace();\n            var allReviewers = CheckablePullRequest.reviewerNames(activeReviews, namespace);\n            var additionalEntries = new ArrayList<String>();\n            for (var additional : Reviewers.reviewers(pr.repository().forge().currentUser(), comments)) {\n                if (!allReviewers.contains(additional)) {\n                    var userInfo = formatUser(null, censusInstance.census().contributor(additional));\n                    additionalEntries.add(\" * \" + userInfo + \" ⚠️ Added manually\");\n                }\n            }\n            if (!reviewers.isBlank()) {\n                reviewers += \"\\n\";\n            }\n            reviewers += String.join(\"\\n\", additionalEntries);\n        }\n\n        if (reviewers.length() > 0) {\n            return Optional.of(reviewers);\n        } else {\n            return Optional.empty();\n        }\n    }\n\n    private String formatContributor(EmailAddress contributor) {\n        var name = contributor.fullName().orElseThrow();\n        return name + \" `<\" + contributor.address() + \">`\";\n    }\n\n    private Optional<String> getContributorsList() {\n        var contributors = Contributors.contributors(pr.repository().forge().currentUser(), comments)\n                                       .stream()\n                                       .map(c -> \" * \" + formatContributor(c))\n                                       .collect(Collectors.joining(\"\\n\"));\n        if (contributors.length() > 0) {\n            return Optional.of(contributors);\n        } else {\n            return Optional.empty();\n        }\n    }\n\n    static boolean relaxedEquals(String s1, String s2) {\n        return s1.trim()\n                 .replaceAll(\"\\\\s+\", \" \")\n                 .equalsIgnoreCase(s2.trim()\n                                     .replaceAll(\"\\\\s+\", \" \"));\n    }\n\n    private String getStatusMessage(PullRequestCheckIssueVisitor visitor,\n            List<String> additionalErrors, Map<String, Boolean> additionalProgresses,\n            List<String> integrationBlockers, List<String> warnings, boolean reviewNeeded,\n            Map<Issue, Optional<IssueTrackerIssue>> regularIssuesMap,\n            IssueTrackerIssue jepIssue, Collection<IssueTrackerIssue> csrIssues, JdkVersion version, boolean tooFewReviewers) {\n        var progressBody = new StringBuilder();\n        progressBody.append(\"---------\\n\");\n        progressBody.append(\"### Progress\\n\");\n        progressBody.append(getChecksList(visitor, reviewNeeded, additionalProgresses));\n\n        var allAdditionalErrors = Stream.concat(visitor.hiddenErrorMessages().stream(), additionalErrors.stream())\n                                        .sorted()\n                                        .collect(Collectors.toList());\n        if (!allAdditionalErrors.isEmpty()) {\n            progressBody.append(\"\\n\\n### Error\");\n            if (allAdditionalErrors.size() > 1) {\n                progressBody.append(\"s\");\n            }\n            progressBody.append(\"\\n\");\n            progressBody.append(warningListToText(allAdditionalErrors));\n        }\n\n        if (!integrationBlockers.isEmpty()) {\n            progressBody.append(\"\\n\\n### Integration blocker\");\n            if (integrationBlockers.size() > 1) {\n                progressBody.append(\"s\");\n            }\n            progressBody.append(\"\\n\");\n            progressBody.append(warningListToText(integrationBlockers));\n        }\n\n        var allWarnings = Stream.concat(visitor.hiddenWarningMessages().stream(), warnings.stream()).toList();\n        if (!allWarnings.isEmpty()) {\n            progressBody.append(\"\\n\\n### Warning\");\n            if (allWarnings.size() > 1) {\n                progressBody.append(\"s\");\n            }\n            progressBody.append(\"\\n\");\n            progressBody.append(warningListToText(allWarnings));\n        }\n\n        // All the issues this pr related(except CSR and JEP)\n        var currentIssues = new HashSet<String>();\n        var issueProject = issueProject();\n        if (issueProject != null && !regularIssuesMap.isEmpty()) {\n            progressBody.append(\"\\n\\n### Issue\");\n            if (regularIssuesMap.size() + csrIssues.size() > 1 || jepIssue != null) {\n                progressBody.append(\"s\");\n            }\n            progressBody.append(\"\\n\");\n\n            var requestPresent = false;\n\n            for (var issueEntry : regularIssuesMap.entrySet()) {\n                var issue = issueEntry.getKey();\n                progressBody.append(\" * \");\n                if (issue.project().isPresent() && !issue.project().get().equals(issueProject.name())) {\n                    progressBody.append(\"⚠️ Issue `\");\n                    progressBody.append(issue.id());\n                    progressBody.append(\"` does not belong to the `\");\n                    progressBody.append(issueProject.name());\n                    progressBody.append(\"` project.\");\n                } else {\n                    var issueTrackerIssue = issueEntry.getValue();\n                    if (issueTrackerIssue.isPresent()) {\n                        currentIssues.add(issueTrackerIssue.get().id());\n                        formatIssue(progressBody, issueTrackerIssue.get());\n                        var issueType = issueTrackerIssue.get().properties().get(\"issuetype\");\n                        if (issueType != null) {\n                            progressBody.append(\" (**\").append(issueType.asString()).append(\"**\");\n                            var issuePriority = issueTrackerIssue.get().properties().get(\"priority\");\n                            if (issuePriority != null) {\n                                progressBody.append(\" - P\").append(issuePriority.asString());\n                            }\n                            if (approvalNeeded()) {\n                                String status = \"\";\n                                var labels = issueTrackerIssue.get().labelNames();\n                                if (labels.contains(approval.rejectedLabel(realTargetRef))) {\n                                    status = \"Rejected\";\n                                } else if (labels.contains(approval.approvedLabel(realTargetRef))) {\n                                    status = \"Approved\";\n                                } else if (labels.contains(approval.requestedLabel(realTargetRef))) {\n                                    status = \"Requested\";\n                                    requestPresent = true;\n                                } else {\n                                    missingApprovalRequest = true;\n                                }\n                                if (!status.isEmpty()) {\n                                    progressBody.append(\" - \").append(status);\n                                }\n                            }\n                            progressBody.append(\")\");\n                        }\n                        if (workItem.bot.versionMismatchWarning() && issueTrackerIssue.get().isOpen()\n                                && version != null && issueType != null && PRIMARY_TYPES.contains(issueType.asString())) {\n                            var existing = Backports.findIssue(issueTrackerIssue.get(), version);\n                            if (existing.isEmpty()) {\n                                var fixVersions = Backports.fixVersions(issueTrackerIssue.get());\n                                progressBody.append(\"(⚠️ The fixVersion in this issue is \" + fixVersions +\n                                        \" but the fixVersion in .jcheck/conf is \" + version.raw() + \", \" +\n                                        \"a new backport will be created when this pr is integrated.)\");\n                            }\n                        }\n                        if (!relaxedEquals(issueTrackerIssue.get().title(), issue.description())) {\n                            progressBody.append(\" ⚠️ Title mismatch between PR and JBS.\");\n                            setExpiration(Duration.ofMinutes(10));\n                        }\n                        if (!issueTrackerIssue.get().isOpen()) {\n                            if (!newLabels.contains(\"backport\") &&\n                                    (issueType == null || !List.of(\"CSR\", \"JEP\").contains(issueType.asString()))) {\n                                if (issueTrackerIssue.get().isFixed()) {\n                                    progressBody.append(\" ⚠️ Issue is already resolved. \" +\n                                            \"Consider making this a \\\"backport pull request\\\" by setting \" +\n                                            \"the PR title to `Backport <hash>` with the hash of the original commit. \" +\n                                            \"See [Backports](https://wiki.openjdk.org/display/SKARA/Backports).\");\n                                } else {\n                                    progressBody.append(\" ⚠️ Issue is not open.\");\n                                }\n                            }\n                        }\n                    } else {\n                        progressBody.append(\"⚠️ Failed to retrieve information on issue `\");\n                        progressBody.append(issue.id());\n                        progressBody.append(\"`.\");\n                    }\n                }\n                progressBody.append(\"\\n\");\n            }\n\n            if (requestPresent) {\n                newLabels.add(APPROVAL_LABEL);\n            } else {\n                newLabels.remove(APPROVAL_LABEL);\n            }\n            if (jepIssue != null) {\n                currentIssues.add(jepIssue.id());\n                progressBody.append(\" * \");\n                formatIssue(progressBody, jepIssue);\n                progressBody.append(\" (**JEP**)\");\n                progressBody.append(\"\\n\");\n            }\n            for (var csrIssue : csrIssues) {\n                currentIssues.add(csrIssue.id());\n                progressBody.append(\" * \");\n                formatIssue(progressBody, csrIssue);\n                progressBody.append(\" (**CSR**)\");\n                if (isWithdrawnCSR(csrIssue)) {\n                    progressBody.append(\" (Withdrawn)\");\n                }\n                progressBody.append(\"\\n\");\n            }\n\n            // Update the issuePRMap\n            var prRecord = new PRRecord(pr.repository().name(), pr.id());\n\n            // Need previousIssues to delete associations\n            var previousIssues = BotUtils.parseAllIssues(pr.body());\n            // Add associations\n            for (String issueId : currentIssues) {\n                if (!previousIssues.contains(issueId)) {\n                    workItem.bot.addIssuePRMapping(issueId, prRecord);\n                }\n            }\n            // Delete associations\n            for (String oldIssueId : previousIssues) {\n                if (!currentIssues.contains(oldIssueId)) {\n                    workItem.bot.removeIssuePRMapping(oldIssueId, prRecord);\n                }\n            }\n        }\n\n        // Generate Reviewers list for recognized users\n        var recognizedReviews = activeReviews.stream()\n                .filter(review -> censusInstance.contributor(review.reviewer()).isPresent())\n                .toList();\n        getReviewersList(recognizedReviews, tooFewReviewers).ifPresent(reviewers -> {\n            progressBody.append(\"\\n\\n### Reviewers\\n\");\n            progressBody.append(reviewers);\n        });\n\n        // Generate Reviewers list for reviewers without OpenJDK IDs\n        var nonRecognizedReviews = activeReviews.stream()\n                .filter(review -> censusInstance.contributor(review.reviewer()).isEmpty())\n                .toList();\n        getReviewersList(nonRecognizedReviews, tooFewReviewers).ifPresent(reviewers -> {\n            progressBody.append(\"\\n\\n### Reviewers without OpenJDK IDs\\n\");\n            progressBody.append(reviewers);\n        });\n\n        getContributorsList().ifPresent(contributors -> {\n            progressBody.append(\"\\n\\n### Contributors\\n\");\n            progressBody.append(contributors);\n        });\n\n        progressBody.append(\"\\n\\n### Reviewing\\n\");\n        progressBody.append(makeCollapsible(\"Using <code>git</code>\", reviewUsingGitHelp()));\n        progressBody.append(makeCollapsible(\"Using Skara CLI tools\", reviewUsingSkaraHelp()));\n        progressBody.append(makeCollapsible(\"Using diff file\", reviewUsingDiffsHelp()));\n\n        var webrevCommentLink = getWebrevCommentLink();\n        if (webrevCommentLink.isPresent()) {\n            progressBody.append(makeCollapsible(\"Using Webrev\", webrevCommentLink.get()));\n        }\n        return progressBody.toString();\n    }\n\n    private static void formatIssue(StringBuilder progressBody, IssueTrackerIssue issueTrackerIssue) {\n        progressBody.append(\"[\");\n        progressBody.append(issueTrackerIssue.id());\n        progressBody.append(\"](\");\n        progressBody.append(issueTrackerIssue.webUrl());\n        progressBody.append(\"): \");\n        progressBody.append(BotUtils.escape(issueTrackerIssue.title()));\n    }\n\n    private Optional<String> getWebrevCommentLink() {\n        var webrevComment = comments.stream()\n                .filter(comment -> comment.author().username().equals(workItem.bot.mlbridgeBotName()))\n                .filter(comment -> comment.body().contains(WEBREV_COMMENT_MARKER))\n                .findFirst();\n        return webrevComment.map(comment -> \"[Link to Webrev Comment](\" + pr.commentUrl(comment).toString() + \")\");\n    }\n\n    private static String makeCollapsible(String summary, String content) {\n        // The linebreaks are important in getting this properly parsed\n        return \"<details><summary>\" + summary + \"</summary>\\n\" +\n                \"\\n\" +\n                content + \"\\n\" +\n                \"</details>\\n\";\n    }\n\n    private String reviewUsingGitHelp() {\n        var repoUrl = pr.repository().url();\n        var firstTime =\n           \"`$ git fetch \" + repoUrl + \" \" + pr.fetchRef() + \":pull/\" + pr.id() + \"` \\\\\\n\" +\n           \"`$ git checkout pull/\" + pr.id() + \"`\\n\";\n        var updating =\n           \"`$ git checkout pull/\" + pr.id() + \"` \\\\\\n\" +\n           \"`$ git pull \" + repoUrl + \" \" + pr.fetchRef() + \"`\\n\";\n\n        return \"Checkout this PR locally: \\\\\\n\" +\n                firstTime +\n                \"\\n\" +\n                \"Update a local copy of the PR: \\\\\\n\" +\n                updating;\n    }\n\n    private String reviewUsingSkaraHelp() {\n        return \"Checkout this PR locally: \\\\\\n\" +\n                (\"`$ git pr checkout \" + pr.id() + \"`\\n\") +\n                \"\\n\" +\n                \"View PR using the GUI difftool: \\\\\\n\" +\n                (\"`$ git pr show -t \" + pr.id() + \"`\\n\");\n    }\n\n    private String reviewUsingDiffsHelp() {\n        var diffUrl = pr.repository().diffUrl(pr.id());\n        return \"Download this PR as a diff file: \\\\\\n\" +\n                \"<a href=\\\"\" + diffUrl + \"\\\">\" + diffUrl + \"</a>\\n\";\n    }\n\n    private String bodyWithoutStatus() {\n        var description = pr.body();\n        var markerIndex = description.lastIndexOf(PROGRESS_MARKER);\n        return (markerIndex < 0 ?\n                description :\n                description.substring(0, markerIndex)).stripTrailing();\n    }\n\n    private String updateStatusMessage(String message) {\n        var description = pr.body();\n        var markerIndex = description.lastIndexOf(PROGRESS_MARKER);\n\n        if (markerIndex >= 0 && description.substring(markerIndex).equals(message)) {\n            log.info(\"Progress already up to date\");\n            return description;\n        }\n        var originalBody = bodyWithoutStatus();\n        if (originalBody.isBlank()) {\n            originalBody = EMPTY_PR_BODY_MARKER;\n        }\n        var newBody = originalBody + \"\\n\\n\" + PROGRESS_MARKER + \"\\n\" + message;\n\n        // Retrieve the body again here to lower the chance of concurrent updates\n        var latestPR = pr.repository().pullRequest(pr.id());\n        if (description.equals(latestPR.body())) {\n            log.info(\"Updating PR body\");\n            pr.setBody(newBody);\n        } else {\n            // The modification should trigger another round of checks, so\n            // no need to force a retry by throwing a RuntimeException.\n            log.info(\"PR body has been modified, won't update PR body this time\");\n            return description;\n        }\n        return newBody;\n    }\n\n    private Optional<Comment> findComment(String marker) {\n        return findComment(comments, marker, pr);\n    }\n\n    static Optional<Comment> findComment(List<Comment> comments, String marker, PullRequest pr) {\n        var self = pr.repository().forge().currentUser();\n        return comments.stream()\n                .filter(comment -> comment.author().equals(self))\n                .filter(comment -> comment.body().contains(marker))\n                .findAny();\n    }\n\n    private String getMergeReadyComment(String commitMessage) {\n        var message = new StringBuilder();\n        message.append(\"@\");\n        message.append(pr.author().username());\n        message.append(\" This change now passes all *automated* pre-integration checks.\");\n\n        try {\n            var hasContributingFile =\n                !localRepo.files(checkablePullRequest.targetHash(), Path.of(\"CONTRIBUTING.md\")).isEmpty();\n            if (hasContributingFile) {\n                message.append(\"\\n\\nℹ️ This project also has non-automated pre-integration requirements. Please see the file \");\n                message.append(\"[CONTRIBUTING.md](https://github.com/\");\n                message.append(pr.repository().name());\n                message.append(\"/blob/\");\n                message.append(pr.targetRef());\n                message.append(\"/CONTRIBUTING.md) for details.\");\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n\n        if (labels.stream().anyMatch(label -> workItem.bot.twentyFourHoursLabels().contains(label))) {\n            var rfrAt = pr.labelAddedAt(\"rfr\");\n            if (rfrAt.isPresent() && ZonedDateTime.now().minusHours(24).isBefore(rfrAt.get())) {\n                message.append(\"\\n\\n\");\n                message.append(\":earth_americas: Applicable reviewers for one or more changes in this pull request are spread across \");\n                message.append(\"multiple different time zones. Please consider waiting with integrating this pull request until it has \");\n                message.append(\"been out for review for at least 24 hours to give all reviewers a chance to review the pull request.\");\n            }\n        }\n\n        message.append(\"\\n\\n\");\n        message.append(\"After integration, the commit message for the final commit will be:\\n\");\n        message.append(\"```\\n\");\n        message.append(commitMessage);\n        message.append(\"\\n```\\n\");\n\n        message.append(\"You can use [pull request commands](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands) \");\n        message.append(\"such as [/summary](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands#PullRequestCommands-/summary), \");\n        message.append(\"[/contributor](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands#PullRequestCommands-/contributor) and \");\n        message.append(\"[/issue](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands#PullRequestCommands-/issue) to adjust it as needed.\");\n        message.append(\"\\n\\n\");\n\n        var divergingCommits = checkablePullRequest.divergingCommits();\n        if (divergingCommits.size() > 0) {\n            message.append(\"\\n\");\n            message.append(\"At the time when this comment was updated there had been \");\n            if (divergingCommits.size() == 1) {\n                message.append(\"1 new commit \");\n            } else {\n                message.append(divergingCommits.size());\n                message.append(\" new commits \");\n            }\n            message.append(\"pushed to the `\");\n            message.append(pr.targetRef());\n            message.append(\"` branch:\\n\\n\");\n            divergingCommits.stream()\n                            .limit(CheckablePullRequest.COMMIT_LIST_LIMIT)\n                            .forEach(c -> message.append(\" * \").append(c.hash().hex()).append(\": \").append(c.message().get(0)).append(\"\\n\"));\n            if (divergingCommits.size() > CheckablePullRequest.COMMIT_LIST_LIMIT) {\n                message.append(\" * ... and \").append(divergingCommits.size() -CheckablePullRequest. COMMIT_LIST_LIMIT).append(\" more: \")\n                       .append(pr.repository().webUrl(baseHash.hex(), pr.targetRef())).append(\"\\n\");\n            } else {\n                message.append(\"\\n\");\n                message.append(\"Please see [this link](\");\n                message.append(pr.repository().webUrl(baseHash.hex(), pr.targetRef()));\n                message.append(\") for an up-to-date comparison between the source branch of this pull request and the `\");\n                message.append(pr.targetRef());\n                message.append(\"` branch.\");\n            }\n\n            message.append(\"\\n\");\n            message.append(\"As there are no conflicts, your changes will automatically be rebased on top of \");\n            message.append(\"these commits when integrating. If you prefer to avoid this automatic rebasing\");\n        } else {\n            message.append(\"\\n\");\n            message.append(\"At the time when this comment was updated there had been no new commits pushed to the `\");\n            message.append(pr.targetRef());\n            message.append(\"` branch. If another commit should be pushed before \");\n            message.append(\"you perform the `/integrate` command, your PR will be automatically rebased. If you prefer to avoid \");\n            message.append(\"any potential automatic rebasing\");\n        }\n        message.append(\", please check the documentation for the \");\n        message.append(\"[/integrate](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands#PullRequestCommands-/integrate) \");\n        message.append(\"command for further details.\\n\");\n\n        if (!censusInstance.isCommitter(pr.author())) {\n            message.append(\"\\n\");\n            message.append(\"As you do not have [Committer](https://openjdk.org/bylaws#committer) status in \");\n            message.append(\"[this project](https://openjdk.org/census#\");\n            message.append(censusInstance.project().name());\n            message.append(\") an existing Committer must agree to \");\n            message.append(\"[sponsor](https://openjdk.org/sponsor/) your change. \");\n            var candidates = activeReviews.stream()\n                                    .filter(review -> censusInstance.isCommitter(review.reviewer()))\n                                    .map(review -> \"@\" + review.reviewer().username())\n                                    .collect(Collectors.joining(\", \"));\n            if (candidates.length() > 0) {\n                message.append(\"Possible candidates are the reviewers of this PR (\");\n                message.append(candidates);\n                message.append(\") but any other Committer may sponsor as well. \");\n            }\n            message.append(\"\\n\\n\");\n            message.append(\"➡️ To flag this PR as ready for integration with the above commit message, type \");\n            message.append(\"`/integrate` in a new comment. (Afterwards, your sponsor types \");\n            message.append(\"`/sponsor` in a new comment to perform the integration).\\n\");\n        } else {\n            message.append(\"\\n\");\n            message.append(\"➡️ To integrate this PR with the above commit message to the `\").append(pr.targetRef()).append(\"` branch, type \");\n            message.append(\"[/integrate](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands#PullRequestCommands-/integrate) \");\n            message.append(\"in a new comment.\\n\");\n        }\n        message.append(MERGE_READY_MARKER);\n        return message.toString();\n    }\n\n    private String getMergeNoLongerReadyComment() {\n        var message = new StringBuilder();\n        message.append(\"@\");\n        message.append(pr.author().username());\n        message.append(\" This change is no longer ready for integration - check the PR body for details.\\n\");\n        message.append(MERGE_READY_MARKER);\n        return message.toString();\n    }\n\n    private void addFullNameWarningComment() {\n        var existing = findComment(FULL_NAME_WARNING_MARKER);\n        if (existing.isPresent()) {\n            // Only warn once\n            return;\n        }\n\n        if (censusInstance.namespace().get(pr.author().id()) != null) {\n            // Known OpenJDK user\n            return;\n        }\n\n        var head = pr.repository().commit(pr.headHash()).orElseThrow(\n            () -> new IllegalStateException(\"Cannot lookup HEAD hash for PR \" + pr.id())\n        );\n        // Normalize the strings before comparison to ensure unicode characters are encoded in the same way\n        var prAuthorFullName = Normalizer.normalize(pr.author().fullName(), Normalizer.Form.NFC);\n        var prAuthorUserName = Normalizer.normalize(pr.author().username(), Normalizer.Form.NFC);\n        var headAuthorName = Normalizer.normalize(head.author().name(), Normalizer.Form.NFC);\n        if (!prAuthorFullName.equals(prAuthorUserName) && !prAuthorFullName.equals(headAuthorName)) {\n            var headUrl = pr.headUrl().toString();\n            var message = \":warning: @\" + pr.author().username() + \" the full name on your profile does not match \" +\n                \"the author name in this pull requests' [HEAD](\" + headUrl + \") commit. \" +\n                          \"If this pull request gets integrated then the author name from this pull requests' \" +\n                          \"[HEAD](\" + headUrl + \") commit will be used for the resulting commit. \" +\n                          \"If you wish to push a new commit with a different author name, \" +\n                          \"then please run the following commands in a local repository of your personal fork:\" +\n                          \"\\n\\n\" +\n                          \"```\\n\" +\n                          \"$ git checkout \" + pr.sourceRef() + \"\\n\" +\n                          \"$ git commit --author='Preferred Full Name <you@example.com>' --allow-empty -m 'Update full name'\\n\" +\n                          \"$ git push\\n\" +\n                          \"```\\n\";\n            pr.addComment(FULL_NAME_WARNING_MARKER + \"\\n\" + message);\n        }\n    }\n\n    private void updateMergeReadyComment(boolean isReady, String commitMessage, boolean rebasePossible) {\n        var existing = findComment(MERGE_READY_MARKER);\n        if (isReady && rebasePossible) {\n            addFullNameWarningComment();\n            var message = getMergeReadyComment(commitMessage);\n            if (existing.isEmpty()) {\n                log.info(\"Adding merge ready comment\");\n                pr.addComment(message);\n            } else {\n                if (!existing.get().body().equals(message)) {\n                    log.info(\"Updating merge ready comment\");\n                    pr.updateComment(existing.get().id(), message);\n                } else {\n                    log.info(\"Merge ready comment already exists, no need to update\");\n                }\n            }\n        } else if (existing.isPresent() && !existing.get().body().contains(PLACEHOLDER_MARKER)) {\n            var message = getMergeNoLongerReadyComment();\n            if (!existing.get().body().equals(message)) {\n                log.info(\"Updating no longer ready comment\");\n                pr.updateComment(existing.get().id(), message);\n            } else {\n                log.info(\"No longer ready comment already exists, no need to update\");\n            }\n        }\n    }\n\n    private void addSourceBranchWarningComment() {\n        var existing = findComment(SOURCE_BRANCH_WARNING_MARKER);\n        if (existing.isPresent()) {\n            // Only add the comment once per PR\n            return;\n        }\n        var branch = pr.sourceRef();\n        var message = \":warning: @\" + pr.author().username() + \" \" +\n            \"a branch with the same name as the source branch for this pull request (`\" + branch + \"`) \" +\n            \"is present in the [target repository](\" + pr.repository().nonTransformedWebUrl() + \"). \" +\n            \"If you eventually integrate this pull request then the branch `\" + branch + \"` \" +\n            \"in your [personal fork](\" + pr.sourceRepository().orElseThrow().nonTransformedWebUrl() + \") will diverge once you sync \" +\n            \"your personal fork with the upstream repository.\\n\" +\n            \"\\n\" +\n            \"To avoid this situation, create a new branch for your changes and reset the `\" + branch + \"` branch. \" +\n            \"You can do this by running the following commands in a local repository for your personal fork. \" +\n            \"_Note_: you do *not* have to name the new branch `NEW-BRANCH-NAME`.\" +\n            \"\\n\" +\n            \"```\" +\n            \"$ git checkout \" + branch + \"\\n\" +\n            \"$ git checkout -b NEW-BRANCH-NAME\\n\" +\n            \"$ git branch -f \" + branch + \" \" + baseHash.hex() + \"\\n\" +\n            \"$ git push -f origin \" + branch + \"\\n\" +\n            \"```\\n\" +\n            \"\\n\" +\n            \"Then proceed to create a new pull request with `NEW-BRANCH-NAME` as the source branch and \" +\n            \"close this one.\\n\" +\n            SOURCE_BRANCH_WARNING_MARKER;\n        log.info(\"Adding source branch warning comment\");\n        pr.addComment(message);\n    }\n\n    private void addOutdatedComment() {\n        var existing = findComment(OUTDATED_HELP_MARKER);\n        if (existing.isPresent()) {\n            // Only add the comment once per PR\n            return;\n        }\n        var message = \"@\" + pr.author().username() + \" this pull request can not be integrated into \" +\n                \"`\" + pr.targetRef() + \"` due to one or more merge conflicts. To resolve these merge conflicts \" +\n                \"and update this pull request you can run the following commands in the local repository for your personal fork:\\n\" +\n                \"```bash\\n\" +\n                \"git checkout \" + pr.sourceRef() + \"\\n\" +\n                \"git fetch \" + pr.repository().url() + \" \" + pr.targetRef() + \"\\n\" +\n                \"git merge FETCH_HEAD\\n\" +\n                \"# resolve conflicts and follow the instructions given by git merge\\n\" +\n                \"git commit -m \\\"Merge \" + pr.targetRef() + \"\\\"\\n\" +\n                \"git push\\n\" +\n                \"```\\n\" +\n                OUTDATED_HELP_MARKER;\n        log.info(\"Adding merge conflict comment\");\n        pr.addComment(message);\n    }\n\n    private void addMergeCommitWarningComment() {\n        var existing = findComment(MERGE_COMMIT_WARNING_MARKER);\n        if (existing.isPresent()) {\n            // Only add the comment once per PR\n            return;\n        }\n\n        var defaultBranch = Branch.defaultFor(VCS.GIT);\n        var message = \"⚠️  @\" + pr.author().username() +\n                      \" This pull request contains merges that bring in commits not present in the target repository.\" +\n                      \" Since this is not a \\\"merge style\\\" pull request, these changes will be squashed when this pull request in integrated.\" +\n                      \" If this is your intention, then please ignore this message. If you want to preserve the commit structure, you must change\" +\n                      \" the title of this pull request to `Merge <project>:<branch>` where `<project>` is the name of another project in the\" +\n                      \" [OpenJDK organization](https://github.com/openjdk) (for example `Merge jdk:\" + defaultBranch + \"`).\\n\" +\n                      MERGE_COMMIT_WARNING_MARKER;\n        log.info(\"Adding merge commit warning comment\");\n        pr.addComment(message);\n    }\n\n    private void addDiffTooLargeWarning() {\n        var existing = findComment(DIFF_TOO_LARGE_WARNING_MARKER);\n        if (existing.isPresent()) {\n            // Only add the comment once per PR\n            return;\n        }\n        var message = \"⚠️  @\" + pr.author().username() +\n                \" This backport pull request is too large to be automatically evaluated as clean. \" +\n                DIFF_TOO_LARGE_WARNING_MARKER;\n        log.info(\"Adding diff too large warning comment\");\n        pr.addComment(message);\n    }\n\n    static String getJcheckName(PullRequest pr) {\n        return pr.repository().forge().name().equals(\"GitHub\") ? \"jcheck-\" + pr.repository().name() + \"-\" + pr.id() : \"jcheck\";\n    }\n\n    private void checkStatus() {\n        var checkBuilder = CheckBuilder.create(getJcheckName(pr), pr.headHash());\n        var censusDomain = censusInstance.configuration().census().domain();\n        var jcheckType = \"jcheck\";\n        Exception checkException = null;\n\n        try {\n            // Post check in-progress\n            log.info(\"Starting to run jcheck on PR head\");\n            pr.createCheck(checkBuilder.build());\n\n            var ignored = new PrintWriter(new StringWriter());\n            var rebasePossible = true;\n            var commitHash = pr.headHash();\n            var mergedHash = checkablePullRequest.mergeTarget(ignored);\n            if (mergedHash.isPresent()) {\n                commitHash = mergedHash.get();\n            } else {\n                rebasePossible = false;\n            }\n\n            var warnings = new ArrayList<String>();\n            var mergeJCheckMessageWithTargetConf = new ArrayList<String>();\n            var mergeJCheckMessageWithCommitConf = new ArrayList<String>();\n            var targetHash = checkablePullRequest.targetHash();\n            var targetJCheckConf = checkablePullRequest.parseJCheckConfiguration(targetHash);\n            var isJCheckConfUpdatedInMergePR = false;\n            var hasOverridingJCheckConf = workItem.bot.confOverrideRepository().isPresent();\n            if (PullRequestUtils.isMerge(pr)) {\n                if (rebasePossible) {\n                    localRepo.lookup(pr.headHash()).ifPresent(this::updateMergeClean);\n                }\n\n                var mergeBaseHash = localRepo.mergeBase(targetHash, pr.headHash());\n                var commits = localRepo.commitMetadata(mergeBaseHash, pr.headHash(), true);\n                isJCheckConfUpdatedInMergePR = isFileUpdated(JCHECK_CONF_PATH, mergeBaseHash, pr.headHash());\n\n                // JCheck all commits in \"Merge PR\"\n                if (workItem.bot.jcheckMerge()) {\n                    for (var commit : commits) {\n                        var hash = commit.hash();\n                        jcheckType = \"merge jcheck with target conf in commit \" + hash.hex();\n                        var targetVisitor = checkablePullRequest.createVisitor(targetJCheckConf);\n                        checkablePullRequest.executeChecks(hash, censusInstance, targetVisitor, targetJCheckConf);\n                        mergeJCheckMessageWithTargetConf.addAll(targetVisitor.errorFailedChecksMessages().stream()\n                                .map(StringBuilder::new)\n                                .map(e -> e.append(\" (in commit `\").append(hash.hex()).append(\"` with target configuration)\"))\n                                .map(StringBuilder::toString)\n                                .toList());\n                        warnings.addAll(targetVisitor.warningFailedChecksMessages().stream()\n                                .map(StringBuilder::new)\n                                .map(e -> e.append(\" (in commit `\").append(hash.hex()).append(\"` with target configuration)\"))\n                                .map(StringBuilder::toString)\n                                .toList());\n\n                        if (!hasOverridingJCheckConf && isJCheckConfUpdatedInMergePR) {\n                            var commitJCheckConf = checkablePullRequest.parseJCheckConfiguration(hash);\n                            var commitVisitor = checkablePullRequest.createVisitor(commitJCheckConf);\n                            jcheckType = \"merge jcheck with commit conf in commit \" + hash.hex();\n                            checkablePullRequest.executeChecks(hash, censusInstance, commitVisitor, commitJCheckConf);\n                            mergeJCheckMessageWithCommitConf.addAll(commitVisitor.errorFailedChecksMessages().stream()\n                                    .map(StringBuilder::new)\n                                    .map(e -> e.append(\" (in commit `\").append(hash.hex()).append(\"` with commit configuration)\"))\n                                    .map(StringBuilder::toString)\n                                    .toList());\n                            warnings.addAll(commitVisitor.warningFailedChecksMessages().stream()\n                                    .map(StringBuilder::new)\n                                    .map(e -> e.append(\" (in commit `\").append(hash.hex()).append(\"` with commit configuration)\"))\n                                    .map(StringBuilder::toString)\n                                    .toList());\n                        }\n\n                    }\n                }\n            }\n\n            var original = backportedFrom();\n            var isCleanBackport = false;\n            if (original.isPresent()) {\n                isCleanBackport = updateClean(original.get());\n            }\n\n            List<String> additionalErrors = List.of();\n            Map<String, Boolean> additionalProgresses = Map.of();\n            List<String> secondJCheckMessage = new ArrayList<>();\n            Hash localHash;\n            try {\n                // Do not pass eventual original commit even for backports since it will cause\n                // the reviewer check to be ignored.\n                localHash = checkablePullRequest.commit(commitHash, censusInstance.namespace(), censusDomain, null, null);\n            } catch (CommitFailure e) {\n                additionalErrors = List.of(e.getMessage());\n                localHash = baseHash;\n            }\n\n            var visitor = checkablePullRequest.createVisitor(targetJCheckConf);\n            boolean tooFewReviewers = false;\n            var needUpdateAdditionalProgresses = false;\n            if (localHash.equals(baseHash)) {\n                if (additionalErrors.isEmpty()) {\n                    additionalErrors = List.of(\"This PR contains no changes\");\n                }\n            } else if (localHash.equals(checkablePullRequest.targetHash())) {\n                additionalErrors = List.of(\"This PR only contains changes already present in the target\");\n            } else {\n                // Determine current status\n                jcheckType = \"target jcheck\";\n                var jcheckIssues = checkablePullRequest.executeChecks(localHash, censusInstance, visitor, targetJCheckConf);\n                tooFewReviewers = jcheckIssues.stream().anyMatch(TooFewReviewersIssue.class::isInstance);\n\n                // If the PR updates .jcheck/conf then Need to run JCheck again using the configuration\n                // from the resulting commit. Not needed if we are overriding the JCheck configuration since\n                // then we won't use the one in the repo anyway.\n                if (!hasOverridingJCheckConf &&\n                        (isFileUpdated(JCHECK_CONF_PATH, localRepo.mergeBase(pr.headHash(), targetHash), pr.headHash()) || isJCheckConfUpdatedInMergePR)) {\n                    jcheckType = \"source jcheck\";\n                    var localJCheckConf = checkablePullRequest.parseJCheckConfiguration(localHash);\n                    var localVisitor = checkablePullRequest.createVisitor(localJCheckConf);\n                    log.info(\"Run JCheck against localHash with configuration from localHash\");\n                    checkablePullRequest.executeChecks(localHash, censusInstance, localVisitor, localJCheckConf);\n                    secondJCheckMessage.addAll(localVisitor.errorFailedChecksMessages().stream()\n                            .map(StringBuilder::new)\n                            .map(e -> e.append(\" (failed with updated jcheck configuration in pull request)\"))\n                            .map(StringBuilder::toString)\n                            .toList());\n                    warnings.addAll(localVisitor.warningFailedChecksMessages().stream()\n                            .map(StringBuilder::new)\n                            .map(e -> e.append(\" (failed with updated jcheck configuration in pull request)\"))\n                            .map(StringBuilder::toString)\n                            .toList());\n                }\n                additionalErrors = botSpecificChecks(isCleanBackport);\n                needUpdateAdditionalProgresses = true;\n            }\n\n            var confFile = localRepo.lines(JCHECK_CONF_PATH, localHash);\n            JdkVersion version = null;\n            if (confFile.isPresent()) {\n                var configuration = JCheckConfiguration.parse(confFile.get());\n                var versionString = configuration.general().version().orElse(null);\n\n                if (versionString != null && !\"\".equals(versionString)) {\n                    version = JdkVersion.parse(versionString).orElse(null);\n                }\n            }\n            // issues without CSR issues and JEP issues\n            var regularIssuesMap = regularIssuesMap();\n            var jepIssue = jepIssue().orElse(null);\n            var issueToAllCsrsMap = issueToAllCsrsMap(regularIssuesMap);\n            var issueToCsrMap = issueToCsrMap(issueToAllCsrsMap, version);\n            var csrIssues = issueToCsrMap.values().stream().toList();\n\n            // Check the status of csr issues and determine whether to add or remove csr label here\n            updateCSRLabel(version, issueToCsrMap);\n\n            // In a backport PR, Check if one of associated issues has a resolved CSR for a different fixVersion\n            updateBackportCSRLabel(issueToAllCsrsMap, issueToCsrMap);\n\n            if (needUpdateAdditionalProgresses) {\n                additionalProgresses = botSpecificProgresses(regularIssuesMap, csrIssues, jepIssue, version);\n            }\n\n            updateCheckBuilder(checkBuilder, visitor, additionalErrors);\n            var readyForReview = updateReadyForReview(visitor, additionalErrors, regularIssuesMap);\n\n            var integrationBlockers = botSpecificIntegrationBlockers(regularIssuesMap);\n            integrationBlockers.addAll(secondJCheckMessage);\n            integrationBlockers.addAll(mergeJCheckMessageWithTargetConf);\n            integrationBlockers.addAll(mergeJCheckMessageWithCommitConf);\n\n            var reviewNeeded = !isCleanBackport || reviewCleanBackport || reviewersCommandIssuedByUser;\n\n            // Calculate and update the status message if needed\n            var statusMessage = getStatusMessage(visitor, additionalErrors, additionalProgresses, integrationBlockers, warnings,\n                    reviewNeeded, regularIssuesMap, jepIssue, issueToCsrMap.values(), version, tooFewReviewers);\n            var updatedBody = updateStatusMessage(statusMessage);\n            var title = pr.title();\n\n            var amendedHash = checkablePullRequest.amendManualReviewersAndStaleReviewers(localHash, censusInstance.namespace(), original.map(Commit::hash).orElse(null));\n            var commit = localRepo.lookup(amendedHash).orElseThrow();\n            var commitMessage = String.join(\"\\n\", commit.message());\n\n            var readyToPostApprovalNeededComment = readyForReview &&\n                    !visitor.hasErrors(reviewNeeded) &&\n                    integrationBlockers.isEmpty() &&\n                    !statusMessage.contains(TEMPORARY_ISSUE_FAILURE_MARKER);\n\n            var readyForIntegration = readyToPostApprovalNeededComment &&\n                    !additionalProgresses.containsValue(false);\n\n            updateMergeReadyComment(readyForIntegration, commitMessage, rebasePossible);\n            if (readyForIntegration && rebasePossible) {\n                newLabels.add(\"ready\");\n            } else {\n                newLabels.remove(\"ready\");\n            }\n            if (!rebasePossible) {\n                if (!labels.contains(\"failed-auto-merge\")) {\n                    addOutdatedComment();\n                }\n                newLabels.add(\"merge-conflict\");\n            } else {\n                newLabels.remove(\"merge-conflict\");\n            }\n\n            if (!PullRequestUtils.isMerge(pr) && !newLabels.contains(\"ready\") && missingApprovalRequest\n                    && approvalNeeded() && approval.approvalComment() && readyToPostApprovalNeededComment) {\n                for (var entry : additionalProgresses.entrySet()) {\n                    if (!entry.getKey().endsWith(\"needs \" + approval.approvalTerm()) && !entry.getValue()) {\n                        readyToPostApprovalNeededComment = false;\n                        break;\n                    }\n                }\n                if (readyToPostApprovalNeededComment) {\n                    postApprovalNeededComment();\n                }\n            }\n\n            if (pr.sourceRepository().isPresent()) {\n                var branchNames = pr.repository().branches().stream().map(HostedBranch::name).collect(Collectors.toSet());\n                if (!pr.repository().url().equals(pr.sourceRepository().get().url()) && branchNames.contains(pr.sourceRef())) {\n                    addSourceBranchWarningComment();\n                }\n            }\n\n            if (!PullRequestUtils.isMerge(pr) && PullRequestUtils.containsForeignMerge(pr, localRepo)) {\n                addMergeCommitWarningComment();\n            }\n\n            // Ensure that the ready for sponsor label is up to date\n            newLabels.remove(\"sponsor\");\n            var readyHash = ReadyForSponsorTracker.latestReadyForSponsor(pr.repository().forge().currentUser(), comments);\n            if (readyHash.isPresent() && readyForIntegration) {\n                var acceptedHash = readyHash.get();\n                if (pr.headHash().equals(acceptedHash)) {\n                    newLabels.add(\"sponsor\");\n                }\n            }\n\n            // Calculate current metadata to avoid unnecessary future checks\n            var metadata = workItem.getMetadata(workItem.getPRMetadata(censusInstance, title, updatedBody, comments, activeReviews,\n                    newLabels, pr.targetRef(), pr.isDraft()), workItem.getIssueMetadata(updatedBody), expiresIn);\n            checkBuilder.metadata(metadata);\n        } catch (Exception e) {\n            log.throwing(\"CommitChecker\", \"checkStatus\", e);\n            newLabels.remove(\"ready\");\n            checkBuilder.metadata(\"invalid\");\n            checkBuilder.title(\"Exception occurred during \" + jcheckType + \" - the operation will be retried\");\n            checkBuilder.summary(e.getMessage());\n            checkBuilder.complete(false);\n            checkException = e;\n        }\n        var check = checkBuilder.build();\n        pr.updateCheck(check);\n\n        // Synchronize the wanted set of labels\n        syncLabels(pr, labels, newLabels, log);\n\n        // After updating the PR, rethrow any exception to automatically retry on transient errors\n        if (checkException != null) {\n            throw new RuntimeException(\"Exception during jcheck\", checkException);\n        }\n    }\n\n    static void syncLabels(PullRequest pr, Set<String> oldLabels, Set<String> newLabels, Logger log) {\n        for (var newLabel : newLabels) {\n            if (!oldLabels.contains(newLabel)) {\n                log.info(\"Adding label \" + newLabel);\n                pr.addLabel(newLabel);\n            }\n        }\n        for (var oldLabel : oldLabels) {\n            if (!newLabels.contains(oldLabel)) {\n                log.info(\"Removing label \" + oldLabel);\n                pr.removeLabel(oldLabel);\n            }\n        }\n    }\n\n    private boolean isFileUpdated(Path filename, Hash from, Hash to) throws IOException {\n        return !localRepo.diff(from, to, List.of(filename)).patches().isEmpty();\n    }\n\n    private void updateCSRLabel(JdkVersion version, Map<String, IssueTrackerIssue> csrIssueTrackerIssueMap) {\n        if (csrIssueTrackerIssueMap.isEmpty()) {\n            return;\n        }\n\n        if (version == null) {\n            log.info(\"No fix version found in `.jcheck/conf` for \" + describe(pr));\n            return;\n        }\n        boolean notExistingUnresolvedCSR = true;\n        boolean existingApprovedCSR = false;\n\n        if (issueProject() == null) {\n            log.info(\"No issue project found for \" + describe(pr));\n            return;\n        }\n\n        for (var csrEntry : csrIssueTrackerIssueMap.entrySet()) {\n            var mainIssueId = csrEntry.getKey();\n            var csr = csrEntry.getValue();\n\n            log.info(\"Found CSR \" + csr.id() + \" for issue \" + mainIssueId + \" for \" + describe(pr));\n\n            var resolutionOpt = csr.resolution();\n            if (resolutionOpt.isEmpty()) {\n                notExistingUnresolvedCSR = false;\n                if (!newLabels.contains(CSR_LABEL)) {\n                    log.info(\"CSR issue resolution is null for csr issue \" + csr.id() + \" for \" + describe(pr) + \", adding the CSR label\");\n                    newLabels.add(CSR_LABEL);\n                } else {\n                    log.info(\"CSR issue resolution is null for csr issue \" + csr.id() + \" for \" + describe(pr) + \", not removing the CSR label\");\n                }\n                continue;\n            }\n\n            var resolution = resolutionOpt.get();\n\n            if (csr.state() != org.openjdk.skara.issuetracker.Issue.State.CLOSED) {\n                notExistingUnresolvedCSR = false;\n                if (!newLabels.contains(CSR_LABEL)) {\n                    log.info(\"CSR issue state is not closed for csr issue \" + csr.id() + \" for \" + describe(pr) + \", adding the CSR label\");\n                    newLabels.add(CSR_LABEL);\n                } else {\n                    log.info(\"CSR issue state is not closed for csr issue\" + csr.id() + \" for \" + describe(pr) + \", not removing the CSR label\");\n                }\n                continue;\n            }\n\n            if (!resolution.equals(\"Approved\")) {\n                if (resolution.equals(\"Withdrawn\")) {\n                    // This condition is necessary to prevent the bot from adding the CSR label again.\n                    // And the bot can't remove the CSR label automatically here.\n                    // Because the PR author with the role of Committer may withdraw a CSR that\n                    // a Reviewer had requested and integrate it without satisfying that requirement.\n                    log.info(\"CSR closed and withdrawn for csr issue \" + csr.id() + \" for \" + describe(pr));\n                } else if (!newLabels.contains(CSR_LABEL)) {\n                    notExistingUnresolvedCSR = false;\n                    log.info(\"CSR issue resolution is not 'Approved' for csr issue \" + csr.id() + \" for \" + describe(pr) + \", adding the CSR label\");\n                    newLabels.add(CSR_LABEL);\n                } else {\n                    notExistingUnresolvedCSR = false;\n                    log.info(\"CSR issue resolution is not 'Approved' for csr issue \" + csr.id() + \" for \" + describe(pr) + \", not removing the CSR label\");\n                }\n            } else {\n                existingApprovedCSR = true;\n            }\n        }\n        if (notExistingUnresolvedCSR && (!isCSRNeeded(comments) || existingApprovedCSR) && newLabels.contains(CSR_LABEL)) {\n            log.info(\"All CSR issues closed and approved for \" + describe(pr) + \", removing CSR label\");\n            newLabels.remove(CSR_LABEL);\n        }\n    }\n\n    private void updateBackportCSRLabel(Map<String, List<IssueTrackerIssue>> issueToAllCsrsMap, Map<String, IssueTrackerIssue> issueToCsrMap) {\n        // Ignore withdrawn CSRs\n        boolean associatedWithValidCSR = issueToCsrMap.values().stream()\n                .anyMatch(value -> !isWithdrawnCSR(value));\n\n        if (newLabels.contains(\"backport\") && !newLabels.contains(\"csr\") && !associatedWithValidCSR && !isCSRManuallyUnneeded(comments)) {\n            boolean hasResolvedCSR = issueToAllCsrsMap.values().stream()\n                    .flatMap(List::stream)\n                    .anyMatch(csrIssue -> csrIssue.state() == org.openjdk.skara.issuetracker.Issue.State.CLOSED &&\n                            csrIssue.resolution().map(res -> res.equals(\"Approved\")).orElse(false));\n\n            if (hasResolvedCSR) {\n                newLabels.add(\"csr\");\n                var existing = findComment(BACKPORT_CSR_MARKER);\n                if (existing.isPresent()) {\n                    return;\n                }\n                pr.addComment(\"At least one of the issues associated with this backport has a resolved \" +\n                        \"[CSR](\" + CSR_PROCESS_LINK + \") for a different version. As this means that this \" +\n                        \"backport may also need a CSR, the `csr` label is being added to this pull request \" +\n                        \"to signal this potential requirement. The command `/csr unneeded` can be used to \" +\n                        \"remove the label in case a CSR is not needed.\" +\n                        BACKPORT_CSR_MARKER);\n            }\n        }\n    }\n\n    private boolean isCSRNeeded(List<Comment> comments) {\n        for (int i = comments.size() - 1; i >= 0; i--) {\n            var comment = comments.get(i);\n            if (comment.body().contains(CSR_NEEDED_MARKER)) {\n                return true;\n            }\n            if (comment.body().contains(CSR_UNNEEDED_MARKER)) {\n                return false;\n            }\n        }\n        return false;\n    }\n\n    private boolean isCSRManuallyUnneeded(List<Comment> comments) {\n        for (int i = comments.size() - 1; i >= 0; i--) {\n            var comment = comments.get(i);\n            if (comment.body().contains(CSR_NEEDED_MARKER)) {\n                return false;\n            }\n            if (comment.body().contains(CSR_UNNEEDED_MARKER)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private String describe(PullRequest pr) {\n        return pr.repository().name() + \"#\" + pr.id();\n    }\n\n    private boolean approvalNeeded() {\n        if (approval != null) {\n            if (realTargetRef == null) {\n                realTargetRef = PreIntegrations.realTargetRef(pr);\n            }\n            return approval.needsApproval(realTargetRef);\n        }\n        return false;\n    }\n\n    private void postApprovalNeededComment() {\n        var existing = findComment(APPROVAL_NEEDED_MARKER);\n        if (existing.isPresent()) {\n            return;\n        }\n        String message = \"⚠️  @\" + pr.author().username() +\n                \" This change is now ready for you to apply for [\" + approval.approvalTerm() + \"](\" + approval.documentLink() + \"). \" +\n                \"This can be done directly in each associated issue or by using the \" +\n                \"[/approval](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands#PullRequestCommands-/approval) \" +\n                \"command.\" +\n                APPROVAL_NEEDED_MARKER;\n        pr.addComment(message);\n    }\n\n    /**\n     * Creates a map from issue ID to a list of all CSRs linked from the issue or any backport of the issue.\n     */\n    private Map<String, List<IssueTrackerIssue>> issueToAllCsrsMap(Map<Issue, Optional<IssueTrackerIssue>> regularIssuesMap) {\n        Map<String, List<IssueTrackerIssue>> issueToAllCsrsMap = new HashMap<>();\n        regularIssuesMap.values().stream()\n                .filter(Optional::isPresent)\n                .map(Optional::get)\n                .forEach(issue -> {\n                    Backports.csrLink(issue)\n                            .flatMap(Link::issue)\n                            .ifPresent(csr -> issueToAllCsrsMap.computeIfAbsent(issue.id(), k -> new ArrayList<>()).add(csr));\n\n                    Backports.findBackports(issue, false).stream()\n                            .map(Backports::csrLink)\n                            .filter(Optional::isPresent)\n                            .map(Optional::get)\n                            .map(Link::issue)\n                            .filter(Optional::isPresent)\n                            .map(Optional::get)\n                            .forEach(backportCsr -> issueToAllCsrsMap.computeIfAbsent(issue.id(), k -> new ArrayList<>()).add(backportCsr));\n\n                });\n        return issueToAllCsrsMap;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/CheckWorkItem.java",
    "content": "/*\n * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport java.util.logging.Level;\n\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.bots.common.BotUtils;\nimport org.openjdk.skara.bots.common.SolvesTracker;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.issuetracker.IssueTrackerIssue;\nimport org.openjdk.skara.json.JSONValue;\nimport org.openjdk.skara.vcs.Branch;\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.Repository;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\nimport org.openjdk.skara.vcs.openjdk.Issue;\n\nimport java.io.*;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Path;\nimport java.security.*;\nimport java.time.*;\nimport java.util.*;\nimport java.util.function.Consumer;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\n\nimport static org.openjdk.skara.bots.common.PullRequestConstants.*;\nimport static org.openjdk.skara.bots.pr.CheckRun.MERGE_READY_MARKER;\nimport static org.openjdk.skara.bots.pr.CheckRun.PLACEHOLDER_MARKER;\nimport static org.openjdk.skara.forge.PullRequestUtils.mergeSourcePattern;\n\nclass CheckWorkItem extends PullRequestWorkItem {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n    static final Pattern ISSUE_ID_PATTERN = Pattern.compile(\"^(?:(?<prefix>[A-Za-z][A-Za-z0-9]+)-)?(?<id>[0-9]+)\"\n            + \"(?:(?:\\\\s*:)?(?<space>[\\\\s\\u00A0\\u2007\\u202F]+)(?<title>.+))?$\");\n    private static final Pattern BACKPORT_HASH_TITLE_PATTERN = Pattern.compile(\"^Backport\\\\s*([0-9a-z]{40})\\\\s*$\", Pattern.CASE_INSENSITIVE);\n    private static final Pattern BACKPORT_ISSUE_TITLE_PATTERN = Pattern.compile(\"^Backport\\\\s*(?:(?<prefix>[A-Za-z][A-Za-z0-9]+)-)?(?<id>[0-9]+)\\\\s*$\", Pattern.CASE_INSENSITIVE);\n    private static final Pattern METADATA_COMMENTS_PATTERN = Pattern.compile(\n            \"<!-- (?:backport)|(?:(add|remove) (?:contributor|reviewer))|(?:summary: ')|(?:solves: ')|(?:additional required reviewers)|(?:jep: ')|(?:csr: ')|(?:trailer:)\");\n    private static final String TWO_REVIEWERS_APPLIED_MARKER = \"<!-- two-reviewers applied -->\";\n    private static final String TWO_REVIEWERS_CLEARED_MARKER = \"<!-- two-reviewers cleared -->\";\n    private static final String ELLIPSIS = \"…\";\n    protected static final String FORCE_PUSH_MARKER = \"<!-- force-push suggestion -->\";\n    protected static final String FORCE_PUSH_SUGGESTION= \"\"\"\n            Please do not rebase or force-push to an active PR as it invalidates existing review comments. \\\n            Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration. \\\n            See [OpenJDK Developers’ Guide](https://openjdk.org/guide/#working-with-pull-requests) for more information.\n            \"\"\";\n\n    private final boolean forceUpdate;\n    private final boolean spawnedFromIssueBot;\n    private final boolean initialRun;\n    private final Map<String, Optional<IssueTrackerIssue>> issues = new HashMap<>();\n\n    @Override\n    public boolean replaces(WorkItem other) {\n        if (!other.getClass().equals(this.getClass())) {\n            return false;\n        }\n        var otherCheckWorkItem = (CheckWorkItem) other;\n        return !concurrentWith(other) && this.forceUpdate == otherCheckWorkItem.forceUpdate\n                && this.initialRun == otherCheckWorkItem.initialRun\n                && this.spawnedFromIssueBot == otherCheckWorkItem.spawnedFromIssueBot;\n    }\n\n    private CheckWorkItem(PullRequestBot bot, String prId, Consumer<RuntimeException> errorHandler, ZonedDateTime triggerUpdatedAt,\n                          boolean needsReadyCheck, boolean forceUpdate, boolean spawnedFromIssueBot, boolean initialRun) {\n        super(bot, prId, errorHandler, triggerUpdatedAt, needsReadyCheck);\n        this.forceUpdate = forceUpdate;\n        this.spawnedFromIssueBot = spawnedFromIssueBot;\n        this.initialRun = initialRun;\n    }\n\n    /**\n     * Create CheckWorkItem spawned from CSRIssueWorkItem\n     */\n    public static CheckWorkItem fromCSRIssue(PullRequestBot bot, String prId, Consumer<RuntimeException> errorHandler, ZonedDateTime triggerUpdatedAt, boolean forceUpdate) {\n        return new CheckWorkItem(bot, prId, errorHandler, triggerUpdatedAt, true, forceUpdate, true, false);\n    }\n\n    /**\n     * Create CheckWorkItem spawned from initial run of PullRequestBot\n     */\n    public static CheckWorkItem fromInitialRunOfPRBot(PullRequestBot bot, String prId, Consumer<RuntimeException> errorHandler, ZonedDateTime triggerUpdatedAt) {\n        return new CheckWorkItem(bot, prId, errorHandler, triggerUpdatedAt, true, false, false, true);\n    }\n\n    /**\n     * Create CheckWorkItem spawned from PullRequestBot\n     */\n    public static CheckWorkItem fromPRBot(PullRequestBot bot, String prId, Consumer<RuntimeException> errorHandler, ZonedDateTime triggerUpdatedAt) {\n        return new CheckWorkItem(bot, prId, errorHandler, triggerUpdatedAt, true, false, false, false);\n    }\n\n    /**\n     * Create CheckWorkItem spawned from IssueBot\n     */\n    public static CheckWorkItem fromIssueBot(PullRequestBot bot, String prId, Consumer<RuntimeException> errorHandler, ZonedDateTime triggerUpdatedAt) {\n        return new CheckWorkItem(bot, prId, errorHandler, triggerUpdatedAt, true, false, true, false);\n    }\n\n    /**\n     * Create Normal CheckWorkItem\n     */\n    public static CheckWorkItem fromWorkItem(PullRequestBot bot, String prId, Consumer<RuntimeException> errorHandler, ZonedDateTime triggerUpdatedAt) {\n        return new CheckWorkItem(bot, prId, errorHandler, triggerUpdatedAt, false, false, false, false);\n    }\n\n    /**\n     * Create Normal CheckWorkItem with force update\n     */\n    public static CheckWorkItem fromWorkItemWithForceUpdate(PullRequestBot bot, String prId, Consumer<RuntimeException> errorHandler, ZonedDateTime triggerUpdatedAt) {\n        return new CheckWorkItem(bot, prId, errorHandler, triggerUpdatedAt, false, true, false, false);\n    }\n\n    private String encodeReviewer(HostUser reviewer, CensusInstance censusInstance) {\n        var census = censusInstance.census();\n        var project = censusInstance.project();\n        var namespace = censusInstance.namespace();\n        var contributor = namespace.get(reviewer.id());\n        if (contributor == null) {\n            return \"unknown-\" + reviewer.id();\n        } else {\n            var censusVersion = census.version().format();\n            var username = contributor.username();\n            return contributor.username() + project.isLead(username, censusVersion) +\n                    project.isReviewer(username, censusVersion) + project.isCommitter(username, censusVersion) +\n                    project.isAuthor(username, censusVersion);\n        }\n    }\n\n    private List<Comment> ensureTwoReviewersLabelMarker(List<Comment> comments) {\n        if (bot.twoReviewersLabels().isEmpty()) {\n            return comments;\n        }\n\n        var botUser = pr.repository().forge().currentUser();\n        // If there is any user issued reviewers command, don't override it\n        var additionalRequiredReviewers = ReviewersTracker.additionalRequiredReviewers(botUser, comments);\n        if (additionalRequiredReviewers.isPresent() && additionalRequiredReviewers.get().source() == ReviewersTracker.Source.USER) {\n            return comments;\n        }\n\n        var latestTwoReviewersComment = comments.reversed().stream()\n                .filter(comment -> comment.author().equals(pr.repository().forge().currentUser()))\n                .filter(comment -> comment.body().contains(TWO_REVIEWERS_APPLIED_MARKER) || comment.body().contains(TWO_REVIEWERS_CLEARED_MARKER))\n                .findFirst();\n\n        if (pr.labelNames().contains(\"backport\") || BACKPORT_ISSUE_TITLE_PATTERN.matcher(pr.title()).matches()\n                || BACKPORT_HASH_TITLE_PATTERN.matcher(pr.title()).matches() || PullRequestUtils.isMerge(pr)) {\n            // Backport or Merge PR\n            if (latestTwoReviewersComment.isEmpty()) {\n                return comments;\n            } else if (latestTwoReviewersComment.get().body().contains(TWO_REVIEWERS_CLEARED_MARKER)) {\n                return comments;\n            } else if (latestTwoReviewersComment.get().body().contains(TWO_REVIEWERS_APPLIED_MARKER)) {\n                var marker = ReviewersTracker.setReviewersMarker(0, \"authors\", ReviewersTracker.Source.BOT);\n                var prType = PullRequestUtils.isMerge(pr) ? \"merge\" : \"backport\";\n                var reviewersClearedComment = pr.addComment(\"This is now a \" + prType + \" PR, the extra reviewers requirement has been cleared.\\n\"\n                        + marker + \"\\n\" + TWO_REVIEWERS_CLEARED_MARKER);\n                return Stream.concat(comments.stream(), Stream.of(reviewersClearedComment)).toList();\n            }\n        } else {\n            // Normal PR\n            if (Collections.disjoint(pr.labelNames(), bot.twoReviewersLabels())) {\n                return comments;\n            }\n\n            if (latestTwoReviewersComment.isEmpty() || latestTwoReviewersComment.get().body().contains(TWO_REVIEWERS_CLEARED_MARKER)) {\n                var matchingLabels = pr.labelNames().stream()\n                        .filter(label -> bot.twoReviewersLabels().contains(label))\n                        .sorted()\n                        .toList();\n                var labelsNoun = matchingLabels.size() == 1 ? \"this label\" : \"these labels\";\n\n                var marker = ReviewersTracker.setReviewersMarker(2, \"authors\", ReviewersTracker.Source.BOT);\n\n                var matchingLabelsList = matchingLabels.stream()\n                        .map(label -> \"`\" + label + \"`\")\n                        .collect(Collectors.joining(\", \"));\n\n                var reviewersAppliedComment = pr.addComment(\n                        \"The total number of required reviews for this PR has been set to 2 based on the presence of \" +\n                                labelsNoun + \": \" + matchingLabelsList + \". \" +\n                                \"This can be overridden with the `/reviewers` command.\\n\" +\n                                marker + \"\\n\" + TWO_REVIEWERS_APPLIED_MARKER);\n                return Stream.concat(comments.stream(), Stream.of(reviewersAppliedComment)).toList();\n            }\n        }\n        return comments;\n    }\n\n    /**\n     * Provides cached fetching of issues from the IssueTracker.\n     * @param shortId Short id of issue to fetch, e.g. the id of an issue is TEST-123, then the short id of the issue is 123\n     * @return The issue if found, otherwise empty.\n     */\n    Optional<IssueTrackerIssue> issueTrackerIssue(String shortId) {\n        if (!issues.containsKey(shortId)) {\n            issues.put(shortId, bot.issueProject().issue(shortId));\n        }\n        return issues.get(shortId);\n    }\n\n    String getPRMetadata(CensusInstance censusInstance, String title, String body, List<Comment> comments,\n                       List<Review> reviews, Set<String> labels, String targetRef, boolean isDraft) {\n        try {\n            var approverString = reviews.stream()\n                                        .filter(review -> review.verdict() == Review.Verdict.APPROVED)\n                                        .filter(review -> review.hash().isPresent())\n                                        .map(review -> encodeReviewer(review.reviewer(), censusInstance) + review.targetRef()\n                                                + review.hash().orElseThrow().hex())\n                                        .sorted()\n                                        .collect(Collectors.joining());\n            var commentString = comments.stream()\n                                        .filter(comment -> comment.author().id().equals(pr.repository().forge().currentUser().id()))\n                                        .flatMap(comment -> comment.body().lines())\n                                        .filter(line -> METADATA_COMMENTS_PATTERN.matcher(line).find())\n                                        .collect(Collectors.joining());\n\n            // Webrev comment should trigger the update\n            commentString = commentString + comments.stream()\n                    .filter(comment -> comment.author().username().equals(bot.mlbridgeBotName()))\n                    .flatMap(comment -> comment.body().lines())\n                    .filter(line -> line.equals(WEBREV_COMMENT_MARKER))\n                    .findFirst().orElse(\"\");\n\n            // Touch command should trigger the update\n            commentString = commentString + comments.stream()\n                    .filter(comment -> comment.author().equals(pr.repository().forge().currentUser()))\n                    .flatMap(comment -> comment.body().lines())\n                    .filter(line -> line.contains(TOUCH_COMMAND_RESPONSE_MARKER))\n                    .collect(Collectors.joining());\n\n            var labelString = labels.stream()\n                                    .sorted()\n                                    .collect(Collectors.joining());\n            var digest = MessageDigest.getInstance(\"SHA-256\");\n            digest.update(title.strip().getBytes(StandardCharsets.UTF_8));\n            digest.update(body.strip().getBytes(StandardCharsets.UTF_8));\n            digest.update(approverString.getBytes(StandardCharsets.UTF_8));\n            digest.update(commentString.getBytes(StandardCharsets.UTF_8));\n            digest.update(labelString.getBytes(StandardCharsets.UTF_8));\n            digest.update(targetRef.getBytes(StandardCharsets.UTF_8));\n            digest.update(censusInstance.configuration().rawJCheckConf().getBytes(StandardCharsets.UTF_8));\n            digest.update(isDraft ? (byte) 0 : (byte) 1);\n\n            return Base64.getUrlEncoder().encodeToString(digest.digest());\n        } catch (NoSuchAlgorithmException e) {\n            throw new RuntimeException(\"Cannot find SHA-256\");\n        }\n    }\n\n    String getIssueMetadata(String prBody) {\n        try {\n            var issueProject = bot.issueProject();\n            if (issueProject == null) {\n                return \"\";\n            }\n            var issueIds = BotUtils.parseAllIssues(prBody);\n            var issuesData = issueIds.stream()\n                    .map(i -> new Issue(i, \"\").shortId())\n                    .sorted()\n                    .map(this::issueTrackerIssue)\n                    .filter(Optional::isPresent)\n                    .map(Optional::get)\n                    .map(issue -> {\n                        var issueData = new StringBuilder();\n                        issueData.append(issue.id());\n                        issueData.append(issue.title());\n                        issueData.append(issue.status());\n                        issue.resolution().ifPresent(issueData::append);\n                        var properties = issue.properties();\n                        if (properties != null) {\n                            issueData.append(properties.get(\"priority\").asString());\n                            issueData.append(properties.get(\"issuetype\").asString());\n                            if (properties.get(\"fixVersions\") != null) {\n                                issueData.append(properties.get(\"fixVersions\").stream()\n                                        .map(JSONValue::asString)\n                                        .sorted()\n                                        .toList());\n                            }\n                        }\n                        if (bot.approval() != null && bot.approval().needsApproval(PreIntegrations.realTargetRef(pr))) {\n                            // Add a static sting to the metadata if the PR needs approval to force\n                            // update if this configuration has changed for the target branch.\n                            issueData.append(\"approval\");\n                            issueData.append(String.join(\"\", issue.labelNames()));\n                        }\n                        return issueData;\n                    })\n                    .collect(Collectors.joining());\n\n            var digest = MessageDigest.getInstance(\"SHA-256\");\n            digest.update(issuesData.getBytes(StandardCharsets.UTF_8));\n            return Base64.getUrlEncoder().encodeToString(digest.digest());\n        } catch (NoSuchAlgorithmException e) {\n            throw new RuntimeException(\"Cannot find SHA-256\");\n        }\n    }\n\n    String getMetadata(String PRMetadata, String issueMetadata, Duration expiresIn) {\n        var ret = PRMetadata + \"#\" + issueMetadata;\n        if (expiresIn != null) {\n            ret += \":\" + Instant.now().plus(expiresIn).getEpochSecond();\n        }\n        return ret;\n    }\n\n    private boolean currentCheckValid(CensusInstance censusInstance, List<Comment> comments, List<Review> reviews, Set<String> labels) {\n        var hash = pr.headHash();\n        var currentChecks = pr.checks(hash);\n        var jcheckName = CheckRun.getJcheckName(pr);\n\n        if (currentChecks.containsKey(jcheckName)) {\n            var check = currentChecks.get(jcheckName);\n            if (check.completedAt().isPresent() && check.metadata().isPresent()) {\n                var previousMetadata = check.metadata().get();\n                Instant expiresAt = null;\n\n                if (previousMetadata.contains(\":\")) {\n                    var splitIndex = previousMetadata.lastIndexOf(\":\");\n                    expiresAt = Instant.ofEpochSecond(Long.parseLong(previousMetadata.substring(splitIndex + 1)));\n                    previousMetadata = previousMetadata.substring(0, splitIndex);\n                }\n\n                String[] substrings = previousMetadata.split(\"#\");\n                String previousPRMetadata = substrings[0];\n                String previousIssueMetadata = (substrings.length > 1) ? substrings[1] : \"\";\n\n                // triggered by issue update or initial run when bot restarts\n                if (initialRun || spawnedFromIssueBot) {\n                    var currIssueMetadata = getIssueMetadata(pr.body());\n                    if (expiresAt != null) {\n                        if (previousIssueMetadata.equals(currIssueMetadata) && expiresAt.isAfter(Instant.now())) {\n                            log.finer(\"[Issue]Metadata with expiration time is still valid, not checking again\");\n                        } else {\n                            log.finer(\"[Issue]Metadata expiration time has expired - checking again\");\n                            return false;\n                        }\n                    } else {\n                        if (previousIssueMetadata.equals(currIssueMetadata)) {\n                            log.fine(\"[Issue]No activity since last check, not checking again.\");\n                        } else {\n                            log.fine(\"[Issue]Previous metadata: \" + previousIssueMetadata + \" - current: \" + currIssueMetadata);\n                            return false;\n                        }\n                    }\n                }\n                // triggered by pr updates\n                if (!spawnedFromIssueBot) {\n                    var currPRMetadata = getPRMetadata(censusInstance, pr.title(), pr.body(), comments, reviews,\n                            labels, pr.targetRef(), pr.isDraft());\n                    if (expiresAt != null) {\n                        if (previousPRMetadata.equals(currPRMetadata) && expiresAt.isAfter(Instant.now())) {\n                            log.finer(\"[PR]Metadata with expiration time is still valid, not checking again\");\n                        } else {\n                            log.finer(\"[PR]Metadata expiration time has expired - checking again\");\n                            return false;\n                        }\n                    } else {\n                        if (previousPRMetadata.equals(currPRMetadata)) {\n                            log.fine(\"[PR]No activity since last check, not checking again.\");\n                        } else {\n                            log.fine(\"[PR]Previous metadata: \" + previousPRMetadata + \" - current: \" + currPRMetadata);\n                            return false;\n                        }\n                    }\n                }\n            } else {\n                log.info(\"Check in progress was never finished - checking again\");\n                return false;\n            }\n        } else {\n            return false;\n        }\n\n        return true;\n    }\n\n    /**\n     * Return the matching group, or the empty string if no match is found\n     */\n    private String getMatchGroup(java.util.regex.Matcher m, String group) {\n        var prefix = m.group(group);\n        if (prefix == null) {\n            return \"\";\n        }\n        return prefix;\n    }\n\n    /**\n     * Help the user by fixing up an \"almost correct\" PR title\n     * @return true if the PR was modified\n     */\n    private boolean updateTitle() {\n        var oldPrTitle = pr.title();\n        var m = ISSUE_ID_PATTERN.matcher(oldPrTitle.trim());\n        var project = bot.issueProject();\n\n        if (m.matches() && project != null) {\n            var prefix = getMatchGroup(m, \"prefix\");\n            var id = getMatchGroup(m,\"id\");\n            var space = getMatchGroup(m, \"space\");\n            var title = getMatchGroup(m,\"title\");\n\n            if (!prefix.isEmpty() && !prefix.equalsIgnoreCase(project.name())) {\n                // If [project-] prefix does not match our project, something is odd;\n                // don't touch the PR title in that case\n                return false;\n            }\n\n            var issue = issueTrackerIssue(id);\n            if (issue.isPresent()) {\n                var issueTitle = issue.get().title();\n                var newPrTitle = id + \": \" + issueTitle;\n                if (title.isEmpty()) {\n                    // If the title is in the form of \"[project-]<bugid>\" only\n                    // we add the title from JBS\n                    pr.setTitle(newPrTitle);\n                    return true;\n                } else {\n                    // If it is \"[project-]<bugid>: <title-pre>\", where <title-pre>\n                    // is a cut-off version of the title, we restore the full title\n                    if (title.endsWith(ELLIPSIS)) {\n                        title = title.substring(0, title.length() - 1);\n                    }\n                    if (issueTitle.startsWith(title) && issueTitle.length() > title.length()) {\n                        pr.setTitle(newPrTitle);\n                        var remainingTitle = issueTitle.substring(title.length());\n                        if (pr.body().startsWith(ELLIPSIS + remainingTitle + \"\\n\\n\")) {\n                            // Remove remaining title, plus decorations\n                            var newPrBody = pr.body().substring(remainingTitle.length() + 3);\n                            pr.setBody(newPrBody);\n                        }\n                        return true;\n                    }\n                    // Automatically update PR title if it's not in canonical form\n                    if (title.equals(issueTitle) && !oldPrTitle.equals(newPrTitle)) {\n                        pr.setTitle(newPrTitle);\n                        return true;\n                    }\n                    // Automatically update PR title if titles are a relaxed match but not an exact match\n                    if (!title.equals(issueTitle) && CheckRun.relaxedEquals(title, issueTitle)) {\n                        pr.setTitle(newPrTitle);\n                        return true;\n                    }\n                }\n            }\n\n            if (!space.equals(\" \")) {\n                // If the space separating the issue and the title is not a single space, rewrite it\n                var newPrTitle = id + \": \" + title;\n                pr.setTitle(newPrTitle);\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    private boolean updateAdditionalIssuesTitle(List<Comment> comments) {\n        if (bot.issueProject() == null) {\n            return false;\n        }\n        var issueTitleUpdated = false;\n        var botUser = pr.repository().forge().currentUser();\n        var issues = SolvesTracker.currentSolved(botUser, comments, pr.title());\n        for (var issue : issues) {\n            var solvesComment = SolvesTracker.getLatestSolvesActionComment(botUser, comments, issue);\n            if (solvesComment.isPresent()) {\n                var issueTrackerIssue = issueTrackerIssue(issue.shortId());\n                if (issueTrackerIssue.isPresent() && !issue.description().equals(issueTrackerIssue.get().title())) {\n                    pr.updateComment(solvesComment.get().id(),\n                            solvesComment.get().body().replace(SolvesTracker.setSolvesMarker(issue),\n                                    SolvesTracker.setSolvesMarker(new Issue(issue.shortId(), issueTrackerIssue.get().title()))));\n                    issueTitleUpdated = true;\n                }\n            }\n        }\n        return issueTitleUpdated;\n    }\n\n    private void initializeIssuePRMap() {\n        // When bot restarts, the issuePRMap needs to get updated with this pr\n        if (!bot.initializedPRs().containsKey(prId)) {\n            var prRecord = new PRRecord(pr.repository().name(), prId);\n            var issueIds = BotUtils.parseAllIssues(pr.body());\n            for (String issueId : issueIds) {\n                bot.addIssuePRMapping(issueId, prRecord);\n            }\n            bot.initializedPRs().put(prId, true);\n        }\n    }\n\n    @Override\n    public String toString() {\n        return \"CheckWorkItem@\" + bot.repo().name() + \"#\" + prId;\n    }\n\n    @Override\n    public Collection<WorkItem> prRun(ScratchArea scratchArea) {\n        var seedPath = bot.seedStorage().orElse(scratchArea.getSeeds());\n        var hostedRepositoryPool = new HostedRepositoryPool(seedPath);\n        CensusInstance census;\n        var comments = prComments();\n        comments = postPlaceholderForReadyComment(comments);\n        comments = ensureTwoReviewersLabelMarker(comments);\n\n        if (pr.headHash().hex() == null) {\n            String text = \"The head hash of this pull request is missing. \" +\n                    \"Until this is resolved, this pull request cannot be processed.\" +\n                    \"This is likely caused by a caching problem in the server \" +\n                    \"and can usually be worked around by pushing another commit to the pull request branch. \" +\n                    \"The commit can be empty. Example:\\n\" +\n                    \"```bash\\n\" +\n                    \"$ git commit --allow-empty -m \\\"Empty commit\\\"\\n\" +\n                    \"$ git push\\n\" +\n                    \"```\\n\" +\n                    \"If the issue still exists, please notify Skara admins.\";\n            addErrorComment(text, comments);\n            return List.of();\n        }\n\n        try {\n            census = CensusInstance.createCensusInstance(hostedRepositoryPool, bot.censusRepo(), bot.censusRef(), scratchArea.getCensus(), pr,\n                    bot.confOverrideRepository().orElse(null), bot.confOverrideName(), bot.confOverrideRef());\n        } catch (MissingJCheckConfException e) {\n            if (bot.confOverrideRepository().isEmpty()) {\n                log.log(Level.SEVERE, \"No .jcheck/conf found in repo \" + bot.repo().name(), e);\n                var text = \" ⚠️ @\" + pr.author().username() + \" No `.jcheck/conf` found in the target branch of this pull request. \"\n                        + \"Until that is resolved, this pull request cannot be processed. Please notify the repository owner.\";\n                addErrorComment(text, comments);\n            } else {\n                log.log(Level.SEVERE, \"Jcheck configuration file \" + bot.confOverrideName()\n                        + \" not found in external repo \" + bot.confOverrideRepository().get().name(), e);\n                var text = \" ⚠️ @\" + pr.author().username() + \" The external jcheck configuration for this repository could not be found. \"\n                        + \"Until that is resolved, this pull request cannot be processed. Please notify a Skara admin.\";\n                addErrorComment(text, comments);\n            }\n            return List.of();\n        } catch (InvalidJCheckConfException e) {\n            if (bot.confOverrideRepository().isEmpty()) {\n                log.log(Level.SEVERE, \"Invalid .jcheck/conf found in repo \" + bot.repo().name(), e);\n                var text = \" ⚠️ @\" + pr.author().username() + \" The `.jcheck/conf` in the target branch of this pull request is invalid. \"\n                        + \"Until that is resolved, this pull request cannot be processed. Please notify the repository owner.\";\n                addErrorComment(text, comments);\n            } else {\n                log.log(Level.SEVERE, \"Invalid Jcheck configuration file \" + bot.confOverrideName()\n                        + \" in external repo \" + bot.confOverrideRepository().get().name(), e);\n                var text = \" ⚠️ @\" + pr.author().username() + \" The external jcheck configuration for this repository is invalid. \"\n                        + \"Until that is resolved, this pull request cannot be processed. Please notify a Skara admin.\";\n                addErrorComment(text, comments);\n            }\n            return List.of();\n        }\n\n        var allReviews = pr.reviews();\n        var labels = new HashSet<>(pr.labelNames());\n        // Filter out the active reviews\n        var activeReviews = CheckablePullRequest.filterActiveReviews(allReviews, pr.targetRef());\n        // initialize issue associations for this pr\n        initializeIssuePRMap();\n        // Determine if the current state of the PR has already been checked\n        if (forceUpdate || !currentCheckValid(census, comments, activeReviews, labels)) {\n            var backportHashMatcher = BACKPORT_HASH_TITLE_PATTERN.matcher(pr.title());\n            var backportIssueMatcher = BACKPORT_ISSUE_TITLE_PATTERN.matcher(pr.title());\n\n            // If backport pr is not allowed, reply warning to the user and return\n            if (!bot.enableBackport() && (backportHashMatcher.matches() || backportIssueMatcher.matches())) {\n                var backportDisabledText = \"<!-- backport error -->\\n\" +\n                        \":warning: @\" + pr.author().username() + \" backports are not allowed in this repository.\" +\n                        \" If it was unintentional, please modify the title of this pull request.\";\n                addErrorComment(backportDisabledText, comments);\n                return List.of();\n            }\n\n            // If merge pr is not allowed, reply warning to the user and return\n            if (!bot.enableMerge() && PullRequestUtils.isMerge(pr)) {\n                var mergeDisabledText = \"<!-- merge error -->\\n\" +\n                        \":warning: @\" + pr.author().username() + \" Merge-style pull requests are not allowed in this repository.\" +\n                        \" If it was unintentional, please modify the title of this PR.\";\n                addErrorComment(mergeDisabledText, comments);\n                return List.of();\n            }\n\n            // If source repo of Merge-style pr is not allowed, reply warning to the user and return\n            if (PullRequestUtils.isMerge(pr)) {\n                var sourceMatcher = mergeSourcePattern.matcher(pr.title());\n                if (sourceMatcher.matches()) {\n                    var source = sourceMatcher.group(1);\n                    if (source.contains(\":\")) {\n                        var repoName = source.split(\":\", 2)[0];\n                        if (!repoName.contains(\"/\")) {\n                            repoName = Path.of(pr.repository().name()).resolveSibling(repoName).toString();\n                        }\n                        // Check repo name\n                        var mergeSources = bot.mergeSources();\n                        if (!mergeSources.isEmpty() && !mergeSources.contains(repoName) && !pr.repository().name().equals(repoName)) {\n                            var mergeSourceInvalidText = \"<!-- merge error -->\\n\" +\n                                    \":warning: @\" + pr.author().username() + \" \" + repoName +\n                                    \" can not be source repo for merge-style pull requests in this repository.\\n\" +\n                                    \"List of valid source repositories: \\n\" +\n                                    String.join(\", \", bot.mergeSources().stream().sorted().toList()) + \".\";\n                            addErrorComment(mergeSourceInvalidText, comments);\n                            return List.of();\n                        }\n                    }\n                }\n            }\n\n            if (labels.contains(\"integrated\")) {\n                log.info(\"Skipping check of integrated PR\");\n                // We still need to make sure any commands get run or are able to finish a\n                // previously interrupted run\n                return List.of(new PullRequestCommandWorkItem(bot, prId, errorHandler, triggerUpdatedAt, false));\n            }\n\n            if (backportHashMatcher.matches()) {\n                var hash = new Hash(backportHashMatcher.group(1));\n                try {\n                    var localRepo = materializeLocalRepo(scratchArea, hostedRepositoryPool);\n                    if (localRepo.isAncestor(hash, pr.headHash())) {\n                        var text = \"<!-- backport error -->\\n\" +\n                                \":warning: @\" + pr.author().username() + \" the given backport hash `\" + hash.hex() +\n                                \"` is an ancestor of your proposed change. Please update the title with the hash for\" +\n                                \" the change you are backporting.\";\n                        addErrorComment(text, comments);\n                        return List.of();\n                    }\n                } catch (IOException e) {\n                    throw new UncheckedIOException(e);\n                }\n                var forge = pr.repository().forge();\n                var repoName = forge.search(hash);\n                if (repoName.isPresent()) {\n                    var commit = forge.repository(repoName.get()).flatMap(repository -> repository.commit(hash));\n                    var message = CommitMessageParsers.v1.parse(commit.orElseThrow().message());\n                    var issues = message.issues();\n                    var comment = new ArrayList<String>();\n                    if (issues.isEmpty()) {\n                        var text = \"<!-- backport error -->\\n\" +\n                                   \":warning: @\" + pr.author().username() + \" the commit `\" + hash.hex() + \"`\" +\n                                   \" does not refer to an issue in project [\" +\n                                   bot.issueProject().name() + \"](\" + bot.issueProject().webUrl() + \").\";\n                        addErrorComment(text, comments);\n                        return List.of();\n                    }\n\n                    var id = issues.get(0).shortId();\n                    var issue = issueTrackerIssue(id);\n                    if (!issue.isPresent()) {\n                        var text = \"<!-- backport error -->\\n\" +\n                                   \":warning: @\" + pr.author().username() + \" the issue with id `\" + id + \"` from commit \" +\n                                   \"`\" + hash.hex() + \"` does not exist in project [\" +\n                                   bot.issueProject().name() + \"](\" + bot.issueProject().webUrl() + \").\";\n                        addErrorComment(text, comments);\n                        return List.of();\n                    }\n                    pr.setTitle(id + \": \" + issue.get().title());\n                    comment.add(\"<!-- backport \" + hash.hex() + \" -->\\n\");\n                    comment.add(\"<!-- repo \" + repoName.get() + \" -->\\n\");\n                    for (var additionalIssue : issues.subList(1, issues.size())) {\n                        comment.add(SolvesTracker.setSolvesMarker(additionalIssue));\n                    }\n                    var summary = message.summaries();\n                    if (!summary.isEmpty()) {\n                        comment.add(Summary.setSummaryMarker(String.join(\"\\n\", summary)));\n                    }\n\n                    var text = \"This backport pull request has now been updated with issue\";\n                    if (issues.size() > 1) {\n                        text += \"s\";\n                    }\n                    if (!summary.isEmpty()) {\n                        text += \" and summary\";\n                    }\n                    text += \" from the original [commit](\" + commit.get().url() + \").\";\n                    comment.add(text);\n                    pr.addComment(String.join(\"\\n\", comment));\n                    pr.addLabel(\"backport\");\n                    return List.of(CheckWorkItem.fromWorkItem(bot, prId, errorHandler, triggerUpdatedAt));\n                } else {\n                    var text = \"<!-- backport error -->\\n\" +\n                            \":warning: @\" + pr.author().username() + \" could not find any commit with hash `\" +\n                            hash.hex() + \"`. Please update the title with the hash for an existing commit.\";\n                    addErrorComment(text, comments);\n                    return List.of();\n                }\n            } else if (pr.title().equals(\"Merge\")) {\n                // Update the PR title with the hash of the commit that will become the second parent\n                // of the final merge commit (the first parent is always the HEAD of the target branch).\n                var targetBranch = new Branch(pr.targetRef());\n                var targetBranchWebUrl = pr.repository().webUrl(targetBranch);\n                var secondParent = pr.headHash();\n                pr.setTitle(\"Merge \" + secondParent.hex());\n                var comment = List.of(\n                    \"<!-- merge parent \" + secondParent.hex() + \"-->\\n\",\n                    \"The first parent of the resulting merge commit from this pull request will be set to the \" +\n                    \"upon integration current `HEAD` of the (\" + targetBranch.name() + \")[\" + targetBranchWebUrl + \"] \" +\n                    \"branch. The second parent of the resulting merge commit from this pull request will be \" +\n                    \"set to `\" + secondParent.hex() + \"`.\"\n                );\n                pr.addComment(String.join(\"\\n\", comment));\n                return List.of(CheckWorkItem.fromWorkItem(bot, prId, errorHandler, triggerUpdatedAt));\n            }\n\n            // Check for a title of the form Backport <issueid>\n            if (backportIssueMatcher.matches()) {\n                var prefix = getMatchGroup(backportIssueMatcher, \"prefix\");\n                var id = getMatchGroup(backportIssueMatcher, \"id\");\n                var project = bot.issueProject();\n\n                if (!prefix.isEmpty() && !prefix.equalsIgnoreCase(project.name())) {\n                    var text = \"<!-- backport error -->\\n\" +\n                            \":warning: @\" + pr.author().username() + \" the issue prefix `\" + prefix + \"` does not\" +\n                            \" match project [\" + project.name() + \"](\" + project.webUrl() + \").\";\n                    addErrorComment(text, comments);\n                    return List.of();\n                }\n                var issue = issueTrackerIssue(id);\n                if (issue.isEmpty()) {\n                    var text = \"<!-- backport error -->\\n\" +\n                            \":warning: @\" + pr.author().username() + \" the issue with id `\" + id + \"` \" +\n                            \"does not exist in project [\" + project.name() + \"](\" + project.webUrl() + \").\";\n                    addErrorComment(text, comments);\n                    return List.of();\n                }\n                pr.setTitle(id + \": \" + issue.get().title());\n                var text = \"This backport pull request has now been updated with the original issue,\" +\n                        \" but not the original commit. If you have the original commit hash, please update\" +\n                        \" the pull request title with `Backport <hash>`.\";\n                var comment = pr.addComment(text);\n                pr.addLabel(\"backport\");\n                logLatency(\"Time from PR updated to backport comment posted \", comment.createdAt(), log);\n                return List.of(CheckWorkItem.fromWorkItem(bot, prId, errorHandler, triggerUpdatedAt));\n            }\n\n            // If the title needs updating, we run the check again\n            if (updateTitle()) {\n                var updatedPr = bot.repo().pullRequest(prId);\n                logLatency(\"Time from PR updated to title corrected \", updatedPr.updatedAt(), log);\n                return List.of(CheckWorkItem.fromWorkItem(bot, prId, errorHandler, triggerUpdatedAt));\n            }\n\n            if (updateAdditionalIssuesTitle(comments)) {\n                var updatedPr = bot.repo().pullRequest(prId);\n                logLatency(\"Time from PR updated to additional issue's title corrected \", updatedPr.updatedAt(), log);\n                return List.of(CheckWorkItem.fromWorkItem(bot, prId, errorHandler, triggerUpdatedAt));\n            }\n\n            // Check force push\n            if (!pr.isDraft()) {\n                var lastForcePushTime = pr.lastForcePushTime();\n                if (lastForcePushTime.isPresent()) {\n                    var lastForcePushSuggestion = comments.stream()\n                            .filter(comment -> comment.body().contains(FORCE_PUSH_MARKER))\n                            .reduce((a, b) -> b);\n                    if (lastForcePushSuggestion.isEmpty() || lastForcePushSuggestion.get().createdAt().isBefore(lastForcePushTime.get())) {\n                        log.info(\"Found force-push for \" + pr.repository().name() + \"#\" + pr.id() + \", adding force-push suggestion\");\n                        pr.addComment(\"@\" + pr.author().username() + \" \" + FORCE_PUSH_SUGGESTION + FORCE_PUSH_MARKER);\n                    }\n                }\n            }\n\n            try {\n                Repository localRepo = materializeLocalRepo(scratchArea, hostedRepositoryPool);\n\n                var expiresAt = CheckRun.execute(this, pr, localRepo, comments, allReviews,\n                        activeReviews, labels, census, bot.useStaleReviews(), bot.integrators(), bot.reviewCleanBackport(),\n                        bot.reviewMerge(), bot.approval(), bot.requiredCheckedLines());\n                if (log.isLoggable(Level.INFO)) {\n                    // Log latency from the original updatedAt of the PR when this WorkItem\n                    // was triggered to when it was just updated by the CheckRun.execute above.\n                    // Both timestamps are taken from the PR data so they originate from the\n                    // same clock (on the forge). Guard this with isLoggable since we need to\n                    // re-fetch the PR data from the forge.\n                    var updatedPr = bot.repo().pullRequest(prId);\n                    logLatency(\"Time from PR updated to CheckRun done \", updatedPr.updatedAt(), log);\n                }\n                if (expiresAt.isPresent()) {\n                    bot.scheduleRecheckAt(pr, expiresAt.get());\n                }\n            } catch (IOException e) {\n                throw new UncheckedIOException(e);\n            }\n        }\n\n        if (pr.isOpen() && pr.labelNames().contains(\"auto\") && pr.labelNames().contains(\"ready\")\n                && !pr.labelNames().contains(\"sponsor\") && !unhandledIntegrateCommand(comments)) {\n            var comment = pr.addComment(\"/integrate\\n\" + PullRequestCommandWorkItem.VALID_BOT_COMMAND_MARKER);\n            var autoAdded = pr.labelAddedAt(\"auto\").orElseThrow();\n            var readyAdded = pr.labelAddedAt(\"ready\").orElseThrow();\n            var latency = Duration.between(autoAdded.isBefore(readyAdded) ? autoAdded : readyAdded, comment.createdAt());\n            log.log(Level.INFO, \"Time from labels added to /integrate posted \" + latency, latency);\n        }\n\n        return List.of(new PullRequestCommandWorkItem(bot, prId, errorHandler, triggerUpdatedAt, false));\n    }\n\n    /**\n     * Only adds comment if not already present\n     */\n    private void addErrorComment(String text, List<Comment> comments) {\n        var botUser = pr.repository().forge().currentUser();\n        if (comments.stream()\n                .filter(c -> c.author().equals(botUser))\n                .noneMatch((c -> c.body().equals(text)))) {\n            var comment = pr.addComment(text);\n            logLatency(\"Time from PR updated to check error posted \", comment.createdAt(), log);\n        }\n    }\n\n    // Lazily initiated\n    private Repository localRepo;\n\n    private Repository materializeLocalRepo(ScratchArea scratchArea, HostedRepositoryPool hostedRepositoryPool) throws IOException {\n        if (localRepo == null) {\n            var localRepoPath = scratchArea.get(pr.repository());\n            localRepo = PullRequestUtils.materialize(hostedRepositoryPool, pr, localRepoPath);\n        }\n        return localRepo;\n    }\n\n    /**\n     * Looks through comments for any /integrate command that has not yet been handled.\n     * Used to avoid double posting /integrate\n     */\n    private boolean unhandledIntegrateCommand(List<Comment> comments) {\n        var allCommands = PullRequestCommandWorkItem.findAllCommands(pr, comments);\n        var handled = PullRequestCommandWorkItem.findHandledCommands(pr, comments);\n        return allCommands.stream()\n                .filter(ci -> ci.name().equals(\"integrate\"))\n                .anyMatch(ci -> !handled.contains(ci.id()));\n    }\n\n    private List<Comment> postPlaceholderForReadyComment(List<Comment> comments) {\n        var existing = comments.stream()\n                .filter(comment -> comment.author().equals(pr.repository().forge().currentUser()))\n                .filter(comment -> comment.body().contains(MERGE_READY_MARKER))\n                .findAny();\n        if (existing.isPresent()) {\n            return comments;\n        }\n        log.info(\"Posting placeholder comment\");\n        String message = \"❗ This change is not yet ready to be integrated.\\n\" +\n                \"See the **Progress** checklist in the description for automated requirements.\\n\" +\n                MERGE_READY_MARKER + \"\\n\" + PLACEHOLDER_MARKER;\n        // If the bot posted a placeholder comment, we should update comments otherwise the bot will not be able to find\n        // comment with MERGE_READY_MARKER later and post merge ready comment again\n        return Stream.concat(comments.stream(), Stream.of(pr.addComment(message))).toList();\n    }\n\n    @Override\n    public String workItemName() {\n        return \"check\";\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/CheckablePullRequest.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.bots.common.SolvesTracker;\nimport org.openjdk.skara.census.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.jcheck.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.*;\nimport org.openjdk.skara.vcs.openjdk.Issue;\n\nimport java.io.*;\nimport java.util.*;\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\n\npublic class CheckablePullRequest {\n    public static final int COMMIT_LIST_LIMIT = 3;\n\n    private static final Pattern BACKPORT_PATTERN = Pattern.compile(\"<!-- backport ([0-9a-z]{40}) -->\");\n    private static final Pattern BACKPORT_REPO_PATTERN = Pattern.compile(\"<!-- repo (.+) -->\");\n\n    private final PullRequest pr;\n    private final Repository localRepo;\n    private final boolean useStaleReviews;\n    private final List<String> confOverride;\n    private final List<Comment> comments;\n    private final MergePullRequestReviewConfiguration reviewMerge;\n    private final ReviewCoverage reviewCoverage;\n\n    CheckablePullRequest(PullRequest pr, Repository localRepo, boolean useStaleReviews,\n            HostedRepository jcheckRepo, String jcheckName, String jcheckRef, List<Comment> comments, MergePullRequestReviewConfiguration reviewMerge, ReviewCoverage reviewCoverage) {\n        this.pr = pr;\n        this.localRepo = localRepo;\n        this.useStaleReviews = useStaleReviews;\n        this.comments = comments;\n        this.reviewMerge = reviewMerge;\n        this.reviewCoverage = reviewCoverage;\n\n        if (jcheckRepo != null) {\n            confOverride = jcheckRepo.fileContents(jcheckName, jcheckRef).orElseThrow(\n                    () -> new RuntimeException(\"Could not find \" + jcheckName + \" on ref \" + jcheckRef + \" in repo \" + jcheckRepo.name())\n            ).lines().collect(Collectors.toList());\n        } else {\n            confOverride = null;\n        }\n    }\n\n    private String commitMessage(Hash head, List<Review> activeReviews, Namespace namespace, boolean manualReviewersAndStaleReviewers, Hash original) throws IOException {\n        var eligibleReviews = activeReviews.stream()\n                                           .filter(reviewCoverage::covers)\n                                           .collect(Collectors.toList());\n        var reviewers = reviewerNames(eligibleReviews, namespace);\n        var currentUser = pr.repository().forge().currentUser();\n\n        if (manualReviewersAndStaleReviewers) {\n            reviewers.addAll(Reviewers.reviewers(currentUser, comments));\n            if (!useStaleReviews) {\n                var staleReviews = new ArrayList<Review>();\n                for (var review : activeReviews) {\n                    if (review.verdict() == Review.Verdict.APPROVED && !eligibleReviews.contains(review)) {\n                        staleReviews.add(review);\n                    }\n                }\n                reviewers.addAll(reviewerNames(staleReviews, namespace));\n            }\n        }\n\n        var additionalContributors = Contributors.contributors(currentUser,\n                                                               comments).stream()\n                                                 .map(email -> Author.fromString(email.toString()))\n                                                 .collect(Collectors.toList());\n        var summary = Summary.summary(currentUser, comments);\n        CommitMessageBuilder commitMessageBuilder;\n        if (PullRequestUtils.isMerge(pr)) {\n            var conf = JCheckConfiguration.from(localRepo, head);\n            var title = pr.title();\n            if (conf.isPresent() && !conf.get().checks().enabled(List.of(new MergeMessageCheck())).isEmpty()) {\n                var mergeConf = conf.get().checks().merge();\n                var pattern = Pattern.compile(mergeConf.message());\n                while (true)  {\n                    var matcher = pattern.matcher(title);\n                    if (matcher.matches()) {\n                        break;\n                    } else {\n                        if (title.length() > 1) {\n                            title = title.substring(0, title.length() - 1);\n                        } else {\n                            throw new RuntimeException(\"Unable to make merge PR title '\" + pr.title() + \"' conform to '\" + mergeConf.message() + \"'\");\n                        }\n                    }\n                }\n            }\n            commitMessageBuilder = CommitMessage.title(title);\n        } else {\n            var issue = Issue.fromStringRelaxed(pr.title());\n            commitMessageBuilder = issue.map(CommitMessage::title).orElseGet(() -> CommitMessage.title(pr.title()));\n            if (issue.isPresent()) {\n                var additionalIssues = SolvesTracker.currentSolved(currentUser, comments, pr.title());\n                commitMessageBuilder.issues(additionalIssues);\n            }\n            if (original != null) {\n                commitMessageBuilder.original(original);\n            }\n        }\n        commitMessageBuilder.contributors(additionalContributors)\n                            .reviewers(new ArrayList<>(reviewers));\n        summary.ifPresent(commitMessageBuilder::summary);\n\n        commitMessageBuilder.customTrailers(Trailers.trailers(currentUser, comments));\n\n        return String.join(\"\\n\", commitMessageBuilder.format(CommitMessageFormatters.v1));\n    }\n\n    /**\n     * The latest one from a particular reviewer is the one that is \"active\".\n     * Always prefer reviews with the same targetRef as the pull request\n     * currently has.\n     */\n    static List<Review> filterActiveReviews(List<Review> allReviews, String targetRef) {\n        var reviewPerUser = new LinkedHashMap<HostUser, Review>();\n        for (var review : allReviews) {\n            if (reviewPerUser.containsKey(review.reviewer())) {\n                var prevReview = reviewPerUser.get(review.reviewer());\n                var prevReviewCorrectTarget = prevReview.targetRef().equals(targetRef);\n                var reviewCorrectTarget = review.targetRef().equals(targetRef);\n                var reviewNewer = prevReview.createdAt().isBefore(review.createdAt());\n\n                if ((!review.verdict().equals(Review.Verdict.NONE))\n                        && ((prevReviewCorrectTarget && reviewCorrectTarget && reviewNewer)\n                        || (!prevReviewCorrectTarget && !reviewCorrectTarget && reviewNewer)\n                        || (!prevReviewCorrectTarget && reviewCorrectTarget))) {\n                    reviewPerUser.put(review.reviewer(), review);\n                }\n            } else {\n                reviewPerUser.put(review.reviewer(), review);\n            }\n        }\n        return List.copyOf(reviewPerUser.values());\n    }\n\n    Hash commit(Hash finalHead, Namespace namespace, String censusDomain, String sponsorId, Hash original) throws IOException, CommitFailure {\n        Author committer;\n        Author author;\n        var contributor = namespace.get(pr.author().id());\n\n        if (contributor == null) {\n            if (PullRequestUtils.isMerge(pr)) {\n                throw new CommitFailure(\"Merge PRs can only be created by known OpenJDK authors.\");\n            }\n\n            if (!pr.author().fullName().isBlank() && pr.author().email().isPresent()) {\n                author = new Author(pr.author().fullName(), pr.author().email().get());\n            } else {\n                var head = localRepo.lookup(pr.headHash()).orElseThrow();\n                author = head.author();\n            }\n        } else {\n            author = new Author(contributor.fullName().orElseThrow(), contributor.username() + \"@\" + censusDomain);\n        }\n\n        if (sponsorId != null) {\n            var sponsorContributor = namespace.get(sponsorId);\n            committer = new Author(sponsorContributor.fullName().orElseThrow(), sponsorContributor.username() + \"@\" + censusDomain);\n        } else {\n            committer = author;\n        }\n\n        var overridingAuthor = OverridingAuthor.author(pr.repository().forge().currentUser(), comments);\n        if (overridingAuthor.isPresent()) {\n            author = new Author(overridingAuthor.get().fullName().orElse(\"\"), overridingAuthor.get().address());\n        }\n\n        var activeReviews = filterActiveReviews(pr.reviews(), pr.targetRef());\n        var commitMessage = commitMessage(finalHead, activeReviews, namespace, false, original);\n        return PullRequestUtils.createCommit(pr, localRepo, finalHead, author, committer, commitMessage);\n    }\n\n    Hash amendManualReviewersAndStaleReviewers(Hash hash, Namespace namespace, Hash original) throws IOException {\n        var activeReviews = filterActiveReviews(pr.reviews(), pr.targetRef());\n        var originalCommitMessage = commitMessage(hash, activeReviews, namespace, false, original);\n        var amendedCommitMessage = commitMessage(hash, activeReviews, namespace, true, original);\n\n        if (originalCommitMessage.equals(amendedCommitMessage)) {\n            return hash;\n        } else {\n            var commit = localRepo.lookup(hash).orElseThrow();\n            return localRepo.amend(amendedCommitMessage, commit.author().name(), commit.author().email(), commit.committer().name(), commit.committer().email());\n        }\n    }\n\n    PullRequestCheckIssueVisitor createVisitor(JCheckConfiguration conf) throws IOException {\n        var checks = JCheck.checksFor(localRepo, conf);\n        return new PullRequestCheckIssueVisitor(checks);\n    }\n\n    JCheckConfiguration parseJCheckConfiguration(Hash hash) throws IOException {\n        var original = confOverride == null ?\n            JCheck.parseConfiguration(localRepo, hash, List.of()) :\n            JCheck.parseConfiguration(confOverride, List.of());\n\n        if (original.isEmpty()) {\n            throw new IllegalStateException(\"Cannot parse JCheck configuration for commit with hash \" + hash.hex());\n        }\n\n        var botUser = pr.repository().forge().currentUser();\n        var additional = AdditionalConfiguration.get(original.get(), botUser, comments, reviewMerge);\n        if (additional.isEmpty()) {\n            return original.get();\n        }\n        var result = confOverride == null ?\n            JCheck.parseConfiguration(localRepo, hash, additional) :\n            JCheck.parseConfiguration(confOverride, additional);\n        return result.orElseThrow(\n                    () -> new IllegalStateException(\"Cannot parse JCheck configuration with additional configuration for commit with hash \" + hash.hex()));\n    }\n\n    List<org.openjdk.skara.jcheck.Issue> executeChecks(Hash hash, CensusInstance censusInstance, PullRequestCheckIssueVisitor visitor, JCheckConfiguration conf) throws IOException {\n        visitor.setConfiguration(conf);\n        try (var issues = JCheck.check(localRepo, censusInstance.census(), CommitMessageParsers.v1, hash, conf)) {\n            var list = issues.asList();\n            for (var issue : list) {\n                issue.accept(visitor);\n            }\n            return list;\n        }\n    }\n\n    List<CommitMetadata> divergingCommits() {\n        return divergingCommits(pr.headHash());\n    }\n\n    private List<CommitMetadata> divergingCommits(Hash commitHash) {\n        try {\n            var updatedBase = localRepo.mergeBase(targetHash(), commitHash);\n            return localRepo.commitMetadata(updatedBase, targetHash());\n        } catch (IOException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    Optional<Hash> mergeTarget(PrintWriter reply) {\n        var divergingCommits = divergingCommits(pr.headHash());\n        if (divergingCommits.size() > 0) {\n            reply.print(\"Since your change was applied there \");\n            if (divergingCommits.size() == 1) {\n                reply.print(\"has been 1 commit \");\n            } else {\n                reply.print(\"have been \");\n                reply.print(divergingCommits.size());\n                reply.print(\" commits \");\n            }\n            reply.print(\"pushed to the `\");\n            reply.print(pr.targetRef());\n            reply.print(\"` branch:\\n\\n\");\n            divergingCommits.stream()\n                            .limit(COMMIT_LIST_LIMIT)\n                            .forEach(c -> reply.println(\" * \" + c.hash().hex() + \": \" + c.message().get(0)));\n            if (divergingCommits.size() > COMMIT_LIST_LIMIT) {\n                try {\n                    var baseHash = localRepo.mergeBase(targetHash(), pr.headHash());\n                    reply.println(\" * ... and \" + (divergingCommits.size() - COMMIT_LIST_LIMIT) + \" more: \" +\n                                          pr.repository().webUrl(baseHash.hex(), pr.targetRef()));\n                } catch (IOException e) {\n                    throw new UncheckedIOException(e);\n                }\n            }\n            reply.println();\n\n            try {\n                localRepo.checkout(pr.headHash(), true);\n                Hash hash;\n                try {\n                    localRepo.merge(targetHash());\n                    hash = localRepo.commit(\"Automatic merge with latest target\", \"duke\", \"duke@openjdk.org\");\n                } catch (IOException e) {\n                    localRepo.abortMerge();\n                    localRepo.rebase(targetHash(), \"duke\", \"duke@openjdk.org\");\n                    hash = localRepo.head();\n                }\n                reply.println();\n                reply.println(\"Your commit was automatically rebased without conflicts.\");\n                return Optional.of(hash);\n            } catch (IOException e) {\n                reply.println();\n                reply.print(\"It was not possible to rebase your changes automatically. Please merge `\");\n                reply.print(pr.targetRef());\n                reply.println(\"` into your branch and try again.\");\n                return Optional.empty();\n            }\n        } else {\n            // No merge needed\n            return Optional.of(pr.headHash());\n        }\n    }\n\n    Hash findOriginalBackportHash() {\n        return findOriginalBackportHash(pr, comments);\n    }\n\n    String findOriginalBackportRepo() {\n        return findOriginalBackportRepo(pr, comments);\n    }\n\n    static Hash findOriginalBackportHash(PullRequest pr, List<Comment> comments) {\n        var botUser = pr.repository().forge().currentUser();\n        return comments\n                .stream()\n                .filter(c -> c.author().equals(botUser))\n                .flatMap(c -> Stream.of(c.body().split(\"\\n\")))\n                .map(BACKPORT_PATTERN::matcher)\n                .filter(Matcher::find)\n                .reduce((first, second) -> second)\n                .map(l -> new Hash(l.group(1)))\n                .orElse(null);\n    }\n\n    static String findOriginalBackportRepo(PullRequest pr, List<Comment> comments) {\n        var botUser = pr.repository().forge().currentUser();\n        return comments\n                .stream()\n                .filter(c -> c.author().equals(botUser))\n                .flatMap(c -> Stream.of(c.body().split(\"\\n\")))\n                .map(BACKPORT_REPO_PATTERN::matcher)\n                .filter(Matcher::find)\n                .reduce((first, second) -> second)\n                .map(l -> l.group(1))\n                .orElse(null);\n    }\n\n    // Lazily initiated\n    private Hash targetHash;\n\n    public Hash targetHash() throws IOException {\n        if (targetHash == null) {\n            targetHash = PullRequestUtils.targetHash(localRepo);\n        }\n        return targetHash;\n    }\n\n    public static Set<String> reviewerNames(List<Review> reviews, Namespace namespace) {\n        return reviews.stream()\n                .map(review -> namespace.get(review.reviewer().id()))\n                .filter(Objects::nonNull)\n                .map(Contributor::username)\n                .collect(Collectors.toCollection(LinkedHashSet::new));\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/CleanCommand.java",
    "content": "/*\n * Copyright (c) 2021, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.io.PrintWriter;\nimport java.util.List;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.clean;\n\npublic class CleanCommand implements CommandHandler {\n    private void showHelp(PrintWriter reply) {\n        reply.println(\"Usage: `/clean`\");\n    }\n\n    @Override\n    public String description() {\n        return \"Mark the backport pull request as a clean backport\";\n    }\n\n    @Override\n    public String name() {\n        return clean.name();\n    }\n\n    @Override\n    public boolean allowedInBody() {\n        return true;\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply)\n    {\n        if (!bot.cleanCommandEnabled()) {\n            reply.println(\"The `/clean` pull request command is not enabled for this repository\");\n            return;\n        }\n\n        if (!censusInstance.isCommitter(command.user())) {\n            reply.println(\"Only OpenJDK [Committers](https://openjdk.org/bylaws#committer) can use the `/clean` command\");\n            return;\n        }\n\n        if (!pr.labelNames().contains(\"backport\") || CheckablePullRequest.findOriginalBackportHash(pr, allComments) == null) {\n            reply.println(\"Can only mark [backport pull requests]\" +\n                    \"(https://wiki.openjdk.org/display/SKARA/Backports#Backports-BackportPullRequests),\" +\n                    \" with an original hash, as clean\");\n            return;\n        }\n\n        if (pr.labelNames().contains(\"clean\")) {\n            reply.println(\"This backport pull request is already marked as clean\");\n            return;\n        }\n\n        pr.addLabel(\"clean\");\n        reply.println(\"This backport pull request is now marked as clean\");\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/CommandExtractor.java",
    "content": "/*\n * Copyright (c) 2020, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport java.time.ZonedDateTime;\n\nimport org.openjdk.skara.bots.common.BotUtils;\nimport org.openjdk.skara.forge.HostedCommit;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.host.HostUser;\n\nimport java.io.PrintWriter;\nimport java.util.*;\nimport java.util.stream.Stream;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.*;\nimport static org.openjdk.skara.bots.common.PatternEnum.EXECUTION_COMMAND_PATTERN;\n\npublic class CommandExtractor {\n\n    private static String formatId(String baseId, int subId) {\n        if (subId > 0) {\n            return String.format(\"%s:%d\", baseId, subId);\n        } else {\n            return baseId;\n        }\n    }\n\n    private static final Map<String, CommandHandler> commandHandlers = Map.ofEntries(\n            Map.entry(help.name(), new HelpCommand()),\n            Map.entry(integrate.name(), new IntegrateCommand()),\n            Map.entry(sponsor.name(), new SponsorCommand()),\n            Map.entry(contributor.name(), new ContributorCommand()),\n            Map.entry(summary.name(), new SummaryCommand()),\n            Map.entry(issue.name(), new IssueCommand()),\n            Map.entry(solves.name(), new IssueCommand(solves.name())),\n            Map.entry(reviewers.name(), new ReviewersCommand()),\n            Map.entry(csr.name(), new CSRCommand()),\n            Map.entry(jep.name(), new JEPCommand()),\n            Map.entry(reviewer.name(), new ReviewerCommand()),\n            Map.entry(label.name(), new LabelCommand()),\n            Map.entry(cc.name(), new LabelCommand(cc.name())),\n            Map.entry(clean.name(), new CleanCommand()),\n            Map.entry(open.name(), new OpenCommand()),\n            Map.entry(backport.name(), new BackportCommand()),\n            Map.entry(tag.name(), new TagCommand()),\n            Map.entry(branch.name(), new BranchCommand()),\n            Map.entry(approval.name(), new ApprovalCommand()),\n            Map.entry(approve.name(), new ApproveCommand()),\n            Map.entry(author.name(), new AuthorCommand()),\n            Map.entry(keepalive.name(), new TouchCommand()),\n            Map.entry(touch.name(), new TouchCommand()),\n            Map.entry(template.name(), new TemplateCommand()),\n            Map.entry(trailer.name(), new TrailerCommand())\n    );\n\n    static class HelpCommand implements CommandHandler {\n        @Override\n        public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n            reply.println(\"Available commands:\");\n            Stream.concat(\n                    commandHandlers.entrySet().stream()\n                            .filter(entry -> entry.getValue().allowedInPullRequest())\n                            .map(entry -> entry.getKey() + \" - \" + entry.getValue().description()),\n                    bot.externalPullRequestCommands().entrySet().stream()\n                            .map(entry -> entry.getKey() + \" - \" + entry.getValue())\n            ).sorted().forEachOrdered(c -> reply.println(\" * \" + c));\n            reply.println();\n            reply.println(\"For additional details, see [Pull Request Commands documentation](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands)\");\n        }\n\n        @Override\n        public void handle(PullRequestBot bot, HostedCommit hash, LimitedCensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n            reply.println(\"Available commands:\");\n            Stream.concat(\n                    commandHandlers.entrySet().stream()\n                            .filter(entry -> entry.getValue().allowedInCommit())\n                            .map(entry -> entry.getKey() + \" - \" + entry.getValue().description()),\n                    bot.externalCommitCommands().entrySet().stream()\n                            .map(entry -> entry.getKey() + \" - \" + entry.getValue())\n            ).sorted().forEachOrdered(c -> reply.println(\" * \" + c));\n            reply.println();\n            reply.println(\"For additional details, see [Commit Commands documentation](https://wiki.openjdk.org/display/SKARA/Commit+Commands)\");\n        }\n\n        @Override\n        public String description() {\n            return \"shows this text\";\n        }\n\n        @Override\n        public String name() {\n            return help.name();\n        }\n\n        @Override\n        public boolean allowedInCommit() {\n            return true;\n        }\n    }\n\n    static List<CommandInvocation> extractCommands(String text, String baseId, HostUser user, ZonedDateTime createdAt) {\n        var ret = new ArrayList<CommandInvocation>();\n        CommandHandler multiLineHandler = null;\n        List<String> multiLineBuffer = null;\n        String multiLineCommand = null;\n        int subId = 0;\n        for (var line : text.split(\"\\\\R\")) {\n            line = BotUtils.preprocessCommandLine(line);\n            var commandMatcher = EXECUTION_COMMAND_PATTERN.getPattern().matcher(line);\n            if (commandMatcher.matches()) {\n                if (multiLineHandler != null) {\n                    ret.add(new CommandInvocation(formatId(baseId, subId++), user, multiLineHandler, multiLineCommand,\n                            String.join(\"\\n\", multiLineBuffer), createdAt));\n                    multiLineHandler = null;\n                }\n                var command = commandMatcher.group(1).toLowerCase();\n                var handler = commandHandlers.get(command);\n                if (handler != null && handler.multiLine()) {\n                    multiLineHandler = handler;\n                    multiLineBuffer = new ArrayList<>();\n                    if (commandMatcher.group(2) != null) {\n                        multiLineBuffer.add(commandMatcher.group(2));\n                    }\n                    multiLineCommand = command;\n                } else {\n                    ret.add(new CommandInvocation(formatId(baseId, subId++), user, handler, command,\n                            commandMatcher.group(2), createdAt));\n                }\n            } else {\n                if (multiLineHandler != null) {\n                    multiLineBuffer.add(line);\n                }\n            }\n        }\n        if (multiLineHandler != null) {\n            ret.add(new CommandInvocation(formatId(baseId, subId), user, multiLineHandler,\n                    multiLineCommand, String.join(\"\\n\", multiLineBuffer), createdAt));\n        }\n        return ret;\n    }\n\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/CommandHandler.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.HostedCommit;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.io.PrintWriter;\nimport java.util.List;\n\ninterface CommandHandler {\n    String description();\n\n    String name();\n\n    default void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea,\n                        CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n    }\n\n    default void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command,\n                        List<Comment> allComments, PrintWriter reply, List<String> labelsToAdd, List<String> labelsToRemove) {\n        handle(bot, pr, censusInstance, scratchArea, command, allComments, reply);\n    }\n\n    default void handle(PullRequestBot bot, HostedCommit commit, LimitedCensusInstance censusInstance, ScratchArea scratchArea,\n            CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n    }\n\n    default boolean multiLine() {\n        return false;\n    }\n    default boolean allowedInBody() {\n        return false;\n    }\n    default boolean allowedInCommit() {\n        return false;\n    }\n    default boolean allowedInPullRequest() {\n        return true;\n    }\n\n    default void printInvalidUserWarning(PullRequestBot bot, PrintWriter reply) {\n        if (bot.repo().forge().name().equals(\"GitHub\")) {\n            reply.println(String.format(\"To use the `/%s` command, you need to be in the OpenJDK [census](https://openjdk.org/census)\"\n                    + \" and your GitHub account needs to be linked with your OpenJDK username\"\n                    + \" ([how to associate your GitHub account with your OpenJDK username]\"\n                    + \"(https://wiki.openjdk.org/display/skara#Skara-AssociatingyourGitHubaccountandyourOpenJDKusername)).\", name()));\n        } else {\n            reply.println(String.format(\"To use the `/%s` command, you need to be listed as a contributor in this [census](%s)\", name(), bot.censusRepo().authenticatedUrl()));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/CommandInvocation.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport java.time.ZonedDateTime;\nimport org.openjdk.skara.host.HostUser;\n\nimport java.util.Optional;\n\nclass CommandInvocation {\n    private final String id;\n    private final HostUser user;\n    private final CommandHandler handler;\n    private final String name;\n    private final String args;\n    private final ZonedDateTime createdAt;\n\n    CommandInvocation(String id, HostUser user, CommandHandler handler, String name, String args, ZonedDateTime createdAt) {\n        this.id = id;\n        this.user = user;\n        this.handler = handler;\n        this.name = name;\n        this.args = args != null ? args.strip() : \"\";\n        this.createdAt = createdAt;\n    }\n\n    String id() {\n        return id;\n    }\n\n    HostUser user() {\n        return user;\n    }\n\n    Optional<CommandHandler> handler() {\n        return Optional.ofNullable(handler);\n    }\n\n    String name() {\n        return name;\n    }\n\n    String args() {\n        return args;\n    }\n\n    ZonedDateTime createdAt() {\n        return createdAt;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/CommitCommandWorkItem.java",
    "content": "/*\n * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.logging.Level;\nimport java.util.logging.Logger;\nimport java.util.regex.*;\nimport java.util.stream.*;\nimport java.util.function.Consumer;\n\npublic class CommitCommandWorkItem implements WorkItem {\n    private final PullRequestBot bot;\n    private final CommitComment commitComment;\n    private final Consumer<RuntimeException> onError;\n\n    static final String COMMAND_REPLY_MARKER = \"<!-- Jmerge command reply message (%s) -->\";\n    static final Pattern COMMAND_REPLY_PATTERN = Pattern.compile(\"<!-- Jmerge command reply message \\\\((\\\\S+)\\\\) -->\");\n\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n\n    CommitCommandWorkItem(PullRequestBot bot, CommitComment commitComment, Consumer<RuntimeException> onError) {\n        this.bot = bot;\n        this.commitComment = commitComment;\n        this.onError = onError;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof CommitCommandWorkItem otherItem)) {\n            return true;\n        }\n        if (!bot.repo().isSame(otherItem.bot.repo())) {\n            return true;\n        }\n        if (!commitComment.id().equals(otherItem.commitComment.id())) {\n            return true;\n        }\n        return false;\n    }\n\n    private Optional<CommandInvocation> nextCommand(List<CommitComment> allComments) {\n        var self = bot.repo().forge().currentUser();\n        var command = CommandExtractor.extractCommands(commitComment.body(),\n                commitComment.id(), commitComment.author(), commitComment.createdAt());\n        if (command.isEmpty()) {\n            return Optional.empty();\n        }\n\n        var handled = allComments.stream()\n                              .filter(c -> c.author().equals(self))\n                              .map(c -> COMMAND_REPLY_PATTERN.matcher(c.body()))\n                              .filter(Matcher::find)\n                              .map(matcher -> matcher.group(1))\n                              .collect(Collectors.toSet());\n\n        if (handled.contains(commitComment.id())) {\n            return Optional.empty();\n        } else {\n            return Optional.of(command.get(0));\n        }\n    }\n\n    private void processCommand(ScratchArea scratchArea, HostedCommit commit, LimitedCensusInstance censusInstance,\n            CommandInvocation command, List<CommitComment> allComments) {\n        var writer = new StringWriter();\n        var printer = new PrintWriter(writer);\n\n        printer.println(String.format(COMMAND_REPLY_MARKER, command.id()));\n        printer.print(\"@\");\n        printer.print(command.user().username());\n        printer.print(\" \");\n\n        var handler = command.handler();\n        if (handler.isPresent()) {\n            if (handler.get().allowedInCommit()) {\n                var comments = allComments.stream()\n                                          .map(cc -> (Comment)cc)\n                                          .collect(Collectors.toList());\n                handler.get().handle(bot, commit, censusInstance, scratchArea, command, comments, printer);\n            } else {\n                printer.print(\"The command `\");\n                printer.print(command.name());\n                printer.println(\"` can only be used in pull requests.\");\n            }\n        } else {\n            printer.print(\"Unknown command `\");\n            printer.print(command.name());\n            printer.println(\"` - for a list of valid commands use `/help`.\");\n        }\n\n        bot.repo().addCommitComment(commitComment.commit(), writer.toString());\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        log.info(\"Looking for commit comment commands\");\n\n        var hash = commitComment.commit();\n        var seedPath = bot.seedStorage().orElse(scratchPath.resolve(\"seeds\"));\n        var hostedRepositoryPool = new HostedRepositoryPool(seedPath);\n        var allComments = bot.repo().commitComments(hash);\n        var nextCommand = nextCommand(allComments);\n        if (nextCommand.isEmpty()) {\n            log.info(\"No new commit comments found, stopping further processing\");\n        } else {\n            LimitedCensusInstance census;\n            var command = nextCommand.get();\n            try {\n                census = LimitedCensusInstance.createLimitedCensusInstance(hostedRepositoryPool, bot.censusRepo(), bot.censusRef(),\n                        scratchPath.resolve(\"census\"), bot.repo(), hash.hex(),\n                        bot.confOverrideRepository().orElse(null),\n                        bot.confOverrideName(),\n                        bot.confOverrideRef());\n            } catch (MissingJCheckConfException e) {\n                if (bot.confOverrideRepository().isEmpty()) {\n                    log.log(Level.SEVERE, \"No .jcheck/conf found in repo \" + bot.repo().name(), e);\n                    var comment = String.format(COMMAND_REPLY_MARKER, command.id()) + \"\\n\" +\n                            \"@\" + command.user().username() +\n                            \" There is no `.jcheck/conf` present at revision \" +\n                            hash.abbreviate() + \" - cannot process command.\";\n                    bot.repo().addCommitComment(hash, comment);\n                } else {\n                    log.log(Level.SEVERE, \"Jcheck configuration file \" + bot.confOverrideName()\n                            + \" not found in external repo \" + bot.confOverrideRepository().get().name(), e);\n                    var comment = String.format(COMMAND_REPLY_MARKER, command.id()) + \"\\n\" +\n                            \"@\" + command.user().username() +\n                            \" There is no Jcheck configuration file \" + bot.confOverrideName()\n                            + \" present in external repo \" + bot.confOverrideRepository().get().name() + \" at revision \"\n                            + hash.abbreviate() + \" - cannot process command.\";\n                    bot.repo().addCommitComment(hash, comment);\n                }\n                return List.of();\n            } catch (InvalidJCheckConfException e) {\n                if (bot.confOverrideRepository().isEmpty()) {\n                    log.log(Level.SEVERE, \"Invalid .jcheck/conf found in repo \" + bot.repo().name(), e);\n                    var comment = String.format(COMMAND_REPLY_MARKER, command.id()) + \"\\n\" +\n                            \"@\" + command.user().username() +\n                            \" Invalid `.jcheck/conf` present at revision \" +\n                            hash.abbreviate() + \" - cannot process command.\";\n                    bot.repo().addCommitComment(hash, comment);\n                } else {\n                    log.log(Level.SEVERE, \"Invalid Jcheck configuration file \" + bot.confOverrideName()\n                            + \" in external repo \" + bot.confOverrideRepository().get().name(), e);\n                    var comment = String.format(COMMAND_REPLY_MARKER, command.id()) + \"\\n\" +\n                            \"@\" + command.user().username() +\n                            \" Invalid Jcheck configuration file \" + bot.confOverrideName() + \" present in external repo \"\n                            + bot.confOverrideRepository().get().name() + \" at revision \" +\n                            hash.abbreviate() + \" - cannot process command.\";\n                    bot.repo().addCommitComment(hash, comment);\n                }\n                return List.of();\n            }\n            var commit = bot.repo().commit(hash).orElseThrow(() ->\n                    new IllegalStateException(\"Commit with hash \" + hash + \" missing\"));\n            processCommand(new ScratchArea(scratchPath, bot.name()), commit, census, command, allComments);\n        }\n        return List.of();\n    }\n\n    @Override\n    public void handleRuntimeException(RuntimeException e) {\n        onError.accept(e);\n    }\n\n    @Override\n    public String toString() {\n        return \"CommitCommandWorkItem@\" + bot.repo().name() + \":\" + commitComment.commit().abbreviate();\n    }\n\n    @Override\n    public String botName() {\n        return PullRequestBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return \"commit-command\";\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/CommitCommentsWorkItem.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\nclass CommitCommentsWorkItem implements WorkItem {\n    private final PullRequestBot bot;\n    private final HostedRepository repo;\n    private final Set<Integer> excludeCommitCommentsFrom;\n\n    private static final ConcurrentHashMap<String, Boolean> processed = new ConcurrentHashMap<>();\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n\n    CommitCommentsWorkItem(PullRequestBot bot, HostedRepository repo, Set<Integer> excludeCommitCommentsFrom) {\n        this.bot = bot;\n        this.repo = repo;\n        this.excludeCommitCommentsFrom = excludeCommitCommentsFrom;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof CommitCommentsWorkItem otherItem)) {\n            return true;\n        }\n        if (!repo.isSame(otherItem.repo)) {\n            return true;\n        }\n        return false;\n    }\n\n    private boolean isAncestor(ReadOnlyRepository repo, Hash ancestor, Hash descendant) {\n        try {\n            return repo.isAncestor(ancestor, descendant);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        log.info(\"Looking for recent commit comments for repository \" + repo.name());\n\n        try {\n            var seedPath = bot.seedStorage().orElse(scratchPath.resolve(\"seeds\"));\n            var hostedRepositoryPool = new HostedRepositoryPool(seedPath);\n            // We are only reading data from this local repo, so no need to make a clone,\n            // just use the seed repo directly.\n            var seedRepo = hostedRepositoryPool.seedRepository(bot.repo(), false);\n            var remoteBranches = bot.repo().branches()\n                                           .stream()\n                                           .filter(b -> !b.name().startsWith(\"pr/\"))\n                                           .toList();\n            var localBranches = remoteBranches.stream().map(b -> new Branch(b.name())).collect(Collectors.toList());\n            var commitComments = repo.recentCommitComments(seedRepo, excludeCommitCommentsFrom,\n                    localBranches, ZonedDateTime.now().minus(Duration.ofDays(4)));\n            return commitComments.stream()\n                                 .filter(cc -> !processed.containsKey(cc.id()))\n                                 .filter(cc -> remoteBranches.stream()\n                                                             .anyMatch(b -> isAncestor(seedRepo, cc.commit(), b.hash())))\n                                 .map(cc -> {\n                                     processed.put(cc.id(), true);\n                                     return new CommitCommandWorkItem(bot, cc, e -> processed.remove(cc.id()));\n                                 })\n                                 .collect(Collectors.toList());\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public String toString() {\n        return \"CommitCommentsWorkItem@\" + repo.name();\n    }\n\n    @Override\n    public String botName() {\n        return PullRequestBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return \"commit-comments\";\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/ContributorCommand.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.census.Contributor;\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.io.PrintWriter;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.contributor;\n\npublic class ContributorCommand implements CommandHandler {\n    private static final Pattern COMMAND_PATTERN = Pattern.compile(\"^(add|remove)\\\\s+(.+)$\");\n\n    private void showHelp(PullRequest pr, PrintWriter reply) {\n        reply.println(\"Syntax: `/contributor (add|remove) [@user | openjdk-user | Full Name <email@address>]`. For example:\");\n        reply.println();\n        reply.println(\" * `/contributor add @openjdk-bot`\");\n        reply.println(\" * `/contributor add duke`\");\n        reply.println(\" * `/contributor add J. Duke <duke@openjdk.org>`\");\n        reply.println();\n        reply.println(\"User names can only be used for users in the census associated with this repository. \" +\n                \"For other contributors you need to supply the full name and email address.\");\n    }\n\n    public static Optional<EmailAddress> parseUser(String user, PullRequest pr, CensusInstance censusInstance, PrintWriter reply) {\n        user = user.strip();\n        if (user.isEmpty()) {\n            reply.println(\"Username parameter is empty.\");\n            return Optional.empty();\n        }\n        Contributor contributor;\n        if (user.charAt(0) == '@') {\n            var platformUser = pr.repository().forge().user(user.substring(1));\n            if (platformUser.isEmpty()) {\n                reply.println(\"`\" + user + \"` is not a valid user in this repository.\");\n                return Optional.empty();\n            }\n            contributor = censusInstance.namespace().get(platformUser.get().id());\n            if (contributor == null) {\n                reply.println(\"`\" + user + \"` was not found in the census.\");\n                return Optional.empty();\n            }\n        } else if (!user.contains(\"@\")) {\n            contributor = censusInstance.census().contributor(user);\n            if (contributor == null) {\n                reply.println(\"`\" + user + \"` was not found in the census.\");\n                return Optional.empty();\n            }\n        } else {\n            try {\n                var email = EmailAddress.parse(user);\n                if (email.fullName().isPresent()) {\n                    return Optional.of(email);\n                } else {\n                    reply.println(\"`\" + user + \"` is not a valid name and email string.\");\n                    return Optional.empty();\n                }\n            } catch (RuntimeException e) {\n                reply.println(\"`\" + user + \"` is not a valid name and email string.\");\n                return Optional.empty();\n            }\n        }\n\n        if (contributor.fullName().isPresent()) {\n            return Optional.of(EmailAddress.from(contributor.fullName().get(), contributor.username() + \"@\" +\n                    censusInstance.configuration().census().domain()));\n        } else {\n            reply.println(\"`\" + user + \"` does not have a full name recorded in the census.\");\n            return Optional.empty();\n        }\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        if (!command.user().equals(pr.author())) {\n            reply.println(\"Only the author (@\" + pr.author().username() + \") is allowed to issue the `contributor` command.\");\n            return;\n        }\n\n        var matcher = COMMAND_PATTERN.matcher(command.args());\n        if (!matcher.matches()) {\n            showHelp(pr, reply);\n            return;\n        }\n\n        var contributor = parseUser(matcher.group(2), pr, censusInstance, reply);\n        if (contributor.isEmpty()) {\n            reply.println();\n            showHelp(pr, reply);;\n            return;\n        }\n\n        switch (matcher.group(1)) {\n            case \"add\": {\n                reply.println(Contributors.addContributorMarker(contributor.get()));\n                reply.println(\"Contributor `\" + contributor.get().toString() + \"` successfully added.\");\n                break;\n            }\n            case \"remove\": {\n                var existing = new HashSet<>(Contributors.contributors(pr.repository().forge().currentUser(), allComments));\n                if (existing.contains(contributor.get())) {\n                    reply.println(Contributors.removeContributorMarker(contributor.get()));\n                    reply.println(\"Contributor `\" + contributor.get().toString() + \"` successfully removed.\");\n                } else {\n                    if (existing.isEmpty()) {\n                        reply.println(\"There are no additional contributors associated with this pull request.\");\n                    } else {\n                        reply.println(\"Contributor `\" + contributor.get().toString() + \"` was not found.\");\n                        reply.println(\"Current additional contributors are:\");\n                        for (var e : existing) {\n                            reply.println(\"- `\" + e.toString() + \"`\");\n                        }\n                    }\n                    break;\n                }\n                break;\n            }\n        }\n    }\n\n    @Override\n    public String description() {\n        return \"adds or removes additional contributors for a PR\";\n    }\n\n    @Override\n    public String name() {\n        return contributor.name();\n    }\n\n    @Override\n    public boolean allowedInBody() {\n        return true;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/Contributors.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.util.*;\nimport java.util.regex.*;\nimport java.util.stream.Collectors;\n\nclass Contributors {\n    private static final String ADD_MARKER = \"<!-- add contributor: '%s' -->\";\n    private static final String REMOVE_MARKER = \"<!-- remove contributor: '%s' -->\";\n    private static final Pattern MARKER_PATTERN = Pattern.compile(\"<!-- (add|remove) contributor: '(.*?)' -->\");\n\n    static String addContributorMarker(EmailAddress contributor) {\n        return String.format(ADD_MARKER, contributor.toString());\n    }\n\n    static String removeContributorMarker(EmailAddress contributor) {\n        return String.format(REMOVE_MARKER, contributor.toString());\n    }\n\n    static List<EmailAddress> contributors(HostUser botUser, List<Comment> comments) {\n        var contributorActions = comments.stream()\n                                         .filter(comment -> comment.author().equals(botUser))\n                                         .map(comment -> MARKER_PATTERN.matcher(comment.body()))\n                                         .filter(Matcher::find)\n                                         .collect(Collectors.toList());\n        var contributors = new LinkedHashSet<EmailAddress>();\n        for (var action : contributorActions) {\n            switch (action.group(1)) {\n                case \"add\":\n                    contributors.add(EmailAddress.parse(action.group(2)));\n                    break;\n                case \"remove\":\n                    contributors.remove(EmailAddress.parse(action.group(2)));\n                    break;\n            }\n        }\n\n        return new ArrayList<>(contributors);\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/IntegrateCommand.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.Repository;\n\nimport java.io.*;\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.logging.Level;\nimport java.util.stream.Collectors;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\nimport java.util.regex.Matcher;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.integrate;\n\npublic class IntegrateCommand implements CommandHandler {\n    private final static Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n    private static final String PRE_PUSH_MARKER = \"<!-- prepush %s -->\";\n    private static final Pattern PRE_PUSH_PATTERN = Pattern.compile(\"<!-- prepush ([0-9a-z]{40}) -->\");\n    private static final Pattern BACKPORT_LABEL_PATTERN = Pattern.compile(\"backport=(.+):(.+)\");\n\n    private enum Command {\n        auto,\n        manual,\n        defer,\n        undefer,\n        delegate,\n        undelegate\n    }\n\n    private void showHelp(PrintWriter reply) {\n        reply.println(\"usage: `/integrate [auto|manual|delegate|undelegate|<hash>]`\");\n    }\n\n    private Optional<String> checkProblem(Map<String, Check> performedChecks, String checkName, PullRequest pr) {\n        final var failure = \"the status check `\" + checkName + \"` did not complete successfully\";\n        final var inProgress = \"the status check `\" + checkName + \"` is still in progress\";\n        final var outdated = \"the status check `\" + checkName + \"` has not been performed on commit %s yet\";\n\n        if (performedChecks.containsKey(checkName)) {\n            var check = performedChecks.get(checkName);\n            if (check.status() == CheckStatus.SUCCESS) {\n                return Optional.empty();\n            } else if (check.status() == CheckStatus.IN_PROGRESS) {\n                return Optional.of(inProgress);\n            } else {\n                return Optional.of(failure);\n            }\n        }\n        return Optional.of(String.format(outdated, pr.headHash()));\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        // Parse any argument given\n        Hash targetHash = null;\n        Command commandArg = null;\n        if (!command.args().isEmpty()) {\n            var args = command.args().split(\" \");\n            if (args.length != 1) {\n                showHelp(reply);\n                return;\n            }\n\n            var arg = args[0].trim();\n            for (Command value : Command.values()) {\n                if (value.name().equals(arg)) {\n                    commandArg = value;\n                }\n            }\n            if (commandArg == null) {\n                targetHash = new Hash(arg);\n                if (!targetHash.isValid()) {\n                    reply.println(\"The given argument, `\" + arg + \"`, is not a valid hash.\");\n                    return;\n                }\n            }\n        }\n\n        if (!command.user().equals(pr.author()) && !command.user().equals(pr.repository().forge().currentUser())) {\n            if (pr.labelNames().contains(\"delegated\")) {\n                // Check that the command user is a committer\n                if (!censusInstance.isCommitter(command.user())) {\n                    reply.print(\"Only project committers are allowed to issue the `integrate` command on a delegated pull request.\");\n                    return;\n                }\n                // Check that no extra arguments are added\n                if (!command.args().isEmpty()) {\n                    reply.print(\"Only the author (@\\\" + pr.author().username() + \\\") is allowed to issue the `integrate` command with arguments.\");\n                    return;\n                }\n            } else {\n                reply.print(\"Only the author (@\" + pr.author().username() + \") is allowed to issue the `integrate` command.\");\n\n                // If the command author is allowed to sponsor this change, suggest that command\n                var readyHash = ReadyForSponsorTracker.latestReadyForSponsor(pr.repository().forge().currentUser(), allComments);\n                if (readyHash.isPresent()) {\n                    if (censusInstance.isCommitter(command.user())) {\n                        reply.print(\" As this pull request is ready to be sponsored, and you are an eligible sponsor, did you mean to issue the `/sponsor` command?\");\n                        return;\n                    }\n                }\n                reply.println();\n                return;\n            }\n        }\n\n        if (commandArg == Command.auto) {\n            pr.addLabel(\"auto\");\n            reply.println(\"This pull request will be automatically integrated when it is ready\");\n            return;\n        } else if (commandArg == Command.manual) {\n            if (pr.labelNames().contains(\"auto\")) {\n                pr.removeLabel(\"auto\");\n            }\n            reply.println(\"This pull request will have to be integrated manually using the \" +\n                    \"[/integrate](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands#PullRequestCommands-/integrate) pull request command.\");\n            return;\n        } else if (commandArg == Command.defer || commandArg == Command.delegate) {\n            pr.addLabel(\"delegated\");\n            if (commandArg == Command.defer) {\n                reply.println(\"Warning: `/integrate defer` is deprecated and will be removed in a future version. Use `/integrate delegate` instead.\");\n            }\n            reply.println(\"Integration of this pull request has been delegated and may be completed by any project committer using the \" +\n                    \"[/integrate](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands#PullRequestCommands-/integrate) pull request command.\");\n            return;\n        } else if (commandArg == Command.undefer || commandArg == Command.undelegate) {\n            if (commandArg == Command.undefer) {\n                reply.println(\"Warning: `/integrate undefer` is deprecated and will be removed in a future version. Use `/integrate undelegate` instead.\");\n            }\n            if (pr.labelNames().contains(\"delegated\")) {\n                reply.println(\"Integration of this pull request is no longer delegated and may only be integrated by the author (@\" + pr.author().username() + \")using the \" +\n                        \"[/integrate](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands#PullRequestCommands-/integrate) pull request command.\");\n                pr.removeLabel(\"delegated\");\n            }\n            reply.println(\"This pull request may now only be integrated by the author\");\n            return;\n        }\n\n        Optional<Hash> prepushHash = checkForPrePushHash(bot, pr, scratchArea, allComments);\n        if (prepushHash.isPresent()) {\n            markIntegratedAndClosed(pr, prepushHash.get(), reply, allComments);\n            return;\n        }\n\n        var problem = checkProblem(pr.checks(pr.headHash()), CheckRun.getJcheckName(pr), pr);\n        if (problem.isPresent()) {\n            reply.print(\"Your integration request cannot be fulfilled at this time, as \");\n            reply.println(problem.get());\n            return;\n        }\n\n        var labels = new HashSet<>(pr.labelNames());\n        if (!labels.contains(\"ready\")) {\n            reply.println(\"This pull request has not yet been marked as ready for integration.\");\n            return;\n        }\n\n        // Run a final jcheck to ensure the change has been properly reviewed\n        try (var integrationLock = IntegrationLock.create(pr, Duration.ofMinutes(10))) {\n            if (!integrationLock.isLocked()) {\n                log.severe(\"Unable to acquire the integration lock for \" + pr.webUrl());\n                reply.print(\"Unable to acquire the integration lock; aborting integration. The error has been logged and will be investigated.\");\n                return;\n            }\n\n            // Now that we have the integration lock, refresh the PR metadata\n            pr = pr.repository().pullRequest(pr.id());\n\n            Repository localRepo = materializeLocalRepo(bot, pr, scratchArea);\n            var checkablePr = new CheckablePullRequest(pr, localRepo, bot.useStaleReviews(),\n                    bot.confOverrideRepository().orElse(null),\n                    bot.confOverrideName(),\n                    bot.confOverrideRef(),\n                    allComments,\n                    bot.reviewMerge(),\n                    new ReviewCoverage(bot.useStaleReviews(), bot.acceptSimpleMerges(), localRepo, pr));\n\n            if (targetHash != null && !checkablePr.targetHash().equals(targetHash)) {\n                reply.print(\"The head of the target branch is no longer at the requested hash \" + targetHash);\n                reply.println(\" - it has moved to \" + checkablePr.targetHash() + \". Aborting integration.\");\n                return;\n            }\n\n            // Now merge the latest changes from the target\n            var rebaseMessage = new StringWriter();\n            var rebaseWriter = new PrintWriter(rebaseMessage);\n            var rebasedHash = checkablePr.mergeTarget(rebaseWriter);\n            if (rebasedHash.isEmpty()) {\n                reply.println(rebaseMessage);\n                return;\n            }\n\n            var original = checkablePr.findOriginalBackportHash();\n            // If someone other than the author or the bot issued the /integrate command, then that person\n            // should be set as sponsor/integrator. Otherwise pass null to use the default author.\n            String committerId = null;\n            if (!command.user().equals(pr.author()) && !command.user().equals(pr.repository().forge().currentUser())) {\n                committerId = command.user().id();\n            }\n            var localHash = checkablePr.commit(rebasedHash.get(), censusInstance.namespace(),\n                    censusInstance.configuration().census().domain(), committerId, original);\n            if (runJcheck(pr, censusInstance, allComments, reply, checkablePr, localHash)) {\n                return;\n            }\n\n            // Finally check if the author is allowed to perform the actual push\n            if (!censusInstance.isCommitter(pr.author())) {\n                reply.println(ReadyForSponsorTracker.addIntegrationMarker(pr.headHash()));\n                reply.println(\"Your change (at version \" + pr.headHash() + \") is now ready to be sponsored by a Committer.\");\n                if (!command.args().isBlank()) {\n                    reply.println(\"Note that your sponsor will make the final decision onto which target hash to integrate.\");\n                }\n                pr.addLabel(\"sponsor\");\n                return;\n            }\n\n            // Rebase and push it!\n            if (!localHash.equals(checkablePr.targetHash())) {\n                var amendedHash = checkablePr.amendManualReviewersAndStaleReviewers(localHash, censusInstance.namespace(), original);\n                addPrePushComment(pr, amendedHash, rebaseMessage.toString());\n                localRepo.push(amendedHash, pr.repository().authenticatedUrl(), pr.targetRef());\n                markIntegratedAndClosed(pr, amendedHash, reply, allComments);\n            } else {\n                reply.print(\"Warning! Your commit did not result in any changes! \");\n                reply.println(\"No push attempt will be made.\");\n            }\n        } catch (IOException | CommitFailure e) {\n            log.log(Level.SEVERE, \"An error occurred during integration (\" + pr.webUrl() + \"): \" + e.getMessage(), e);\n            reply.println(\"An unexpected error occurred during integration. No push attempt will be made. \" +\n                                  \"The error has been logged and will be investigated. It is possible that this error \" +\n                                  \"is caused by a transient issue; feel free to retry the operation.\");\n        }\n    }\n\n    /**\n     * Runs the checks adding to the reply message and returns true if any of them failed\n     */\n    static boolean runJcheck(PullRequest pr, CensusInstance censusInstance, List<Comment> allComments,\n                             PrintWriter reply, CheckablePullRequest checkablePr, Hash localHash) throws IOException {\n        var targetHash = checkablePr.targetHash();\n        var jcheckConf = checkablePr.parseJCheckConfiguration(targetHash);\n        var visitor = checkablePr.createVisitor(jcheckConf);\n        checkablePr.executeChecks(localHash, censusInstance, visitor, jcheckConf);\n        if (!visitor.errorFailedChecksMessages().isEmpty()) {\n            reply.print(\"Your integration request cannot be fulfilled at this time, as \");\n            reply.println(\"your changes failed the final jcheck:\");\n            visitor.errorFailedChecksMessages().stream()\n                  .map(line -> \" * \" + line)\n                  .forEach(reply::println);\n            return true;\n        }\n        return false;\n    }\n\n    static Repository materializeLocalRepo(PullRequestBot bot, PullRequest pr, ScratchArea scratchArea) throws IOException {\n        var path = scratchArea.get(pr.repository());\n        var seedPath = bot.seedStorage().orElse(scratchArea.getSeeds());\n        var hostedRepositoryPool = new HostedRepositoryPool(seedPath);\n        return PullRequestUtils.materialize(hostedRepositoryPool, pr, path);\n    }\n\n    /**\n     * Checks if a prepush comment has been created already. This could happen if\n     * the bot got interrupted after pushing, but before finishing closing the PR\n     * and adding the final push comment.\n     */\n    static Optional<Hash> checkForPrePushHash(PullRequestBot bot, PullRequest pr, ScratchArea scratchArea,\n                                              List<Comment> allComments) {\n        var botUser = pr.repository().forge().currentUser();\n        var prePushHashes = allComments.stream()\n                .filter(c -> c.author().equals(botUser))\n                .map(Comment::body)\n                .map(PRE_PUSH_PATTERN::matcher)\n                .filter(Matcher::find)\n                .map(m -> m.group(1))\n                .collect(Collectors.toList());\n        if (!prePushHashes.isEmpty()) {\n            try {\n                var localRepo = materializeLocalRepo(bot, pr, scratchArea);\n                for (String prePushHash : prePushHashes) {\n                    Hash hash = new Hash(prePushHash);\n                    if (PullRequestUtils.isAncestorOfTarget(localRepo, hash)) {\n                        // A previous attempt at pushing this PR was successful, but didn't finish\n                        // closing the PR\n                        log.info(\"Found previous successful push in prepush comment: \" + hash.hex());\n                        return Optional.of(hash);\n                    }\n                }\n            } catch (IOException e) {\n                throw new UncheckedIOException(e);\n            }\n        }\n        return Optional.empty();\n    }\n\n    static void addPrePushComment(PullRequest pr, Hash hash, String extraMessage) {\n        var commentBody = new StringWriter();\n        var writer = new PrintWriter(commentBody);\n        writer.println(PRE_PUSH_MARKER.formatted(hash.hex()));\n        writer.println(\"Going to push as commit \" + hash.hex() + \".\");\n        if (!extraMessage.isBlank()) {\n            writer.println(extraMessage);\n        }\n        pr.addComment(commentBody.toString());\n    }\n\n    private static void processBackportLabel(PullRequest pr, List<Comment> allComments) {\n        var botUser = pr.repository().forge().currentUser();\n        for (String label : pr.labelNames()) {\n            var matcher = BACKPORT_LABEL_PATTERN.matcher(label);\n            if (matcher.matches()) {\n                var repoName = matcher.group(1);\n                var branchName = matcher.group(2);\n                var text = \"Creating backport for repo \" + repoName + \" on branch \" + branchName\n                        + \"\\n\\n/backport \" + repoName + \" \" + branchName + \"\\n\"\n                        + PullRequestCommandWorkItem.VALID_BOT_COMMAND_MARKER;\n                if (allComments.stream()\n                        .filter(c -> c.author().equals(botUser))\n                        .noneMatch(((c -> c.body().equals(text))))) {\n                    pr.addComment(text);\n                }\n                pr.removeLabel(label);\n            }\n        }\n    }\n\n    static void markIntegratedAndClosed(PullRequest pr, Hash hash, PrintWriter reply, List<Comment> allComments) {\n        processBackportLabel(pr, allComments);\n        // Note that the order of operations here is tested in IntegrateTests::retryAfterInterrupt\n        // so any change here requires careful update of that test\n        pr.addLabel(\"integrated\");\n        pr.setState(PullRequest.State.CLOSED);\n        pr.removeLabel(\"ready\");\n        pr.removeLabel(\"rfr\");\n        if (pr.labelNames().contains(\"delegated\")) {\n            pr.removeLabel(\"delegated\");\n        }\n        if (pr.labelNames().contains(\"sponsor\")) {\n            pr.removeLabel(\"sponsor\");\n        }\n        reply.println(PullRequest.commitHashMessage(hash));\n        reply.println();\n        reply.println(\":bulb: You may see a message that your pull request was closed with unmerged commits. This can be safely ignored.\");\n    }\n\n    @Override\n    public String description() {\n        return \"performs integration of the changes in the PR\";\n    }\n\n    @Override\n    public String name() {\n        return integrate.name();\n    }\n\n    @Override\n    public boolean allowedInBody() {\n        return true;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/IntegrationLock.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.PullRequest;\n\nimport java.time.Duration;\nimport java.util.concurrent.*;\n\npublic class IntegrationLock implements AutoCloseable {\n    private static final ConcurrentHashMap<String, Semaphore> pendingIntegrations = new ConcurrentHashMap<>();\n\n    static IntegrationLock create(PullRequest pr, Duration timeout) {\n        var repoName = pr.repository().webUrl().toString();\n        var repoPending = pendingIntegrations.computeIfAbsent(repoName, key -> new Semaphore(1));\n        try {\n            var locked = repoPending.tryAcquire(timeout.toMillis(), TimeUnit.MILLISECONDS);\n            return new IntegrationLock(locked ? repoPending : null);\n        } catch (InterruptedException e) {\n            return new IntegrationLock(null);\n        }\n    }\n\n    private final Semaphore semaphore;\n\n    private IntegrationLock(Semaphore semaphore) {\n        this.semaphore = semaphore;\n    }\n\n    @Override\n    public void close() {\n        if (semaphore != null) {\n            semaphore.release();\n        }\n    }\n\n    public boolean isLocked() {\n        return semaphore != null;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/IssueBot.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.bot.Bot;\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.issuetracker.IssueProjectPoller;\nimport org.openjdk.skara.issuetracker.IssueProject;\n\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport java.util.LinkedList;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.logging.Logger;\nimport org.openjdk.skara.issuetracker.IssueTrackerIssue;\n\nclass IssueBot implements Bot {\n    private final IssueProject issueProject;\n    private final List<HostedRepository> repositories;\n    private final IssueProjectPoller poller;\n\n    private final Map<String, PullRequestBot> pullRequestBotMap;\n    private final Map<String, List<PRRecord>> issuePRMap;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n\n    IssueBot(IssueProject issueProject, List<HostedRepository> repositories, Map<String, PullRequestBot> pullRequestBotMap,\n                    Map<String, List<PRRecord>> issuePRMap) {\n        this.issueProject = issueProject;\n        this.repositories = repositories;\n        this.pullRequestBotMap = pullRequestBotMap;\n        this.issuePRMap = issuePRMap;\n        // The PullRequestBot will initially evaluate all active PRs so there\n        // is no need to look at any issues older than the start time of the bot\n        // here. A padding of 10 minutes for the initial query should cover any\n        // potential time difference between local and remote, as well as timing\n        // issues between the first run of each bot, without the risk of\n        // returning excessive amounts of Issues in the first run.\n        this.poller = new IssueProjectPoller(issueProject, Duration.ofMinutes(10)) {\n            // Query for non-CSR and non-JEP issues in this poller.\n            @Override\n            protected List<IssueTrackerIssue> queryIssues(IssueProject issueProject, ZonedDateTime updatedAfter) {\n                return issueProject.issues(updatedAfter).stream()\n                        .filter(issue -> {\n                            var issueType = issue.properties().get(\"issuetype\");\n                            return issueType != null && !\"CSR\".equals(issueType.asString());\n                        })\n                        .toList();\n            }\n        };\n    }\n\n    @Override\n    public String toString() {\n        return \"IssueBot@\" + issueProject.name();\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        var issues = poller.updatedIssues();\n        log.info(\"Found \" + issues.size() + \" updated issues(exclude CSR issues)\");\n        var items = new LinkedList<WorkItem>();\n        for (var issue : issues) {\n            var prRecords = issuePRMap.get(issue.id());\n            if (prRecords == null) {\n                continue;\n            }\n            prRecords.stream()\n                    .flatMap(record -> repositories.stream()\n                            .filter(r -> r.name().equals(record.repoName()))\n                            .map(r -> r.pullRequest(record.prId()))\n                    )\n                    .filter(Issue::isOpen)\n                    // This will mix time stamps from the IssueTracker and the Forge hosting PRs, but it's the\n                    // best we can do.\n                    .map(pr -> CheckWorkItem.fromIssueBot(pullRequestBotMap.get(pr.repository().name()), pr.id(),\n                            e -> poller.retryIssue(issue), issue.updatedAt()))\n                    .forEach(items::add);\n        }\n        poller.lastBatchHandled();\n        return items;\n    }\n\n    @Override\n    public String name() {\n        return PullRequestBotFactory.NAME + \"-issue\";\n    }\n\n    List<HostedRepository> repositories() {\n        return repositories;\n    }\n\n    Map<String, List<PRRecord>> issuePRMap() {\n        return issuePRMap;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/IssueCommand.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.bots.common.SolvesTracker;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.vcs.openjdk.Issue;\n\nimport java.io.PrintWriter;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\nimport static org.openjdk.skara.issuetracker.jira.JiraProject.SUBCOMPONENT;\nimport static org.openjdk.skara.bots.common.CommandNameEnum.issue;\nclass InvalidIssue extends Exception {\n    private String identifier;\n    private String reason;\n\n    InvalidIssue(String identifier, String reason) {\n        this.identifier = identifier;\n        this.reason = reason;\n    }\n\n    String identifier() {\n        return identifier;\n    }\n\n    String reason() {\n        return reason;\n    }\n}\n\npublic class IssueCommand implements CommandHandler {\n    private final String name;\n\n    private void showHelp(PrintWriter reply) {\n        reply.println(\"Command syntax:\");\n        reply.println(\" * `/\" + name + \" [add|remove] <id>[,<id>,...]`\");\n        reply.println(\" * `/\" + name + \" [add] <id>: <description>`\");\n        reply.println();\n        reply.println(\"Some examples:\");\n        reply.println();\n        reply.println(\" * `/\" + name + \" add JDK-1234567,4567890`\");\n        reply.println(\" * `/\" + name + \" remove JDK-4567890`\");\n        reply.println(\" * `/\" + name + \" 1234567: Use this exact title`\");\n        reply.println();\n        reply.print(\"If issues are specified only by their ID, the title will be automatically retrieved from JBS. \");\n        reply.print(\"The project prefix (`JDK-` in the above examples) is optional. \");\n        reply.println(\"Separate multiple issue IDs using either spaces or commas.\");\n    }\n\n    private static final Pattern SHORT_ISSUE_PATTERN = Pattern.compile(\"((?:[A-Za-z]+-)?[0-9]+)(?:,| |$)\");\n    private static final Pattern SUBCOMMAND_PATTERN = Pattern.compile(\"^(add|remove|delete|(?:[A-Za-z]+-)?[0-9]+:?)[ ,]?.*$\");\n\n    private List<Issue> parseIssueList(String allowedPrefix, String issueList) throws InvalidIssue {\n        List<Issue> ret;\n        // Is this a single fully described issue?\n        var singleIssue = Issue.fromString(issueList);\n        if (singleIssue.isPresent()) {\n            ret = List.of(singleIssue.get());\n        } else {\n            var shortIssueMatcher = SHORT_ISSUE_PATTERN.matcher(issueList);\n            ret = shortIssueMatcher.results()\n                                   .map(matchResult -> matchResult.group(1))\n                                   .map(identifier -> new Issue(identifier, null))\n                                   .collect(Collectors.toList());\n        }\n        for (var issue : ret) {\n            if (issue.project().isPresent() && !issue.project().get().equalsIgnoreCase(allowedPrefix)) {\n                throw new InvalidIssue(issue.id(), \"This PR can only solve issues in the \" + allowedPrefix + \" project\");\n            }\n        }\n\n        return ret;\n    }\n\n    IssueCommand(String name) {\n        this.name = name;\n    }\n\n    IssueCommand() {\n        this(\"issue\");\n    }\n\n    private void addIssue(PullRequestBot bot, PullRequest pr, String args, Set<String> currentSolved, PrintWriter reply) throws InvalidIssue {\n        if (args.startsWith(\"add\")) {\n            var issueListStart = args.indexOf(' ');\n            if (issueListStart == -1) {\n                showHelp(reply);\n                return;\n            }\n            args = args.substring(issueListStart);\n        }\n        var issues = parseIssueList(bot.issueProject() == null ? \"\" : bot.issueProject().name(), args);\n        if (issues.size() == 0) {\n            showHelp(reply);\n            return;\n        }\n        var validatedIssues = new ArrayList<Issue>();\n        for (var issue : issues) {\n            try {\n                if (bot.issueProject() == null) {\n                    if (issue.description() == null) {\n                        reply.print(\"This repository does not have an issue project configured - you will need to input the issue title manually \");\n                        reply.println(\"using the syntax `/\" + name + \" \" + issue.shortId() + \": title of the issue`.\");\n                        return;\n                    } else {\n                        validatedIssues.add(issue);\n                        continue;\n                    }\n                }\n                var validatedIssue = bot.issueProject().issue(issue.shortId());\n                if (validatedIssue.isEmpty()) {\n                    reply.println(\"The issue `\" + issue.shortId() + \"` was not found in the `\" + bot.issueProject().name() + \"` project - make sure you have entered it correctly.\");\n                    continue;\n                }\n                if (issue.description() == null) {\n                    validatedIssues.add(new Issue(validatedIssue.get().id(), validatedIssue.get().title()));\n                } else {\n                    validatedIssues.add(new Issue(validatedIssue.get().id(), issue.description()));\n                }\n\n            } catch (RuntimeException e) {\n                if (issue.description() == null) {\n                    reply.print(\"Temporary failure when trying to look up issue `\" + issue.shortId() + \"` - you will need to input the issue title manually \");\n                    reply.println(\"using the syntax `/\" + name + \" \" + issue.shortId() + \": title of the issue`.\");\n                    return;\n                } else {\n                    validatedIssues.add(issue);\n                }\n            }\n        }\n        if (validatedIssues.size() != issues.size()) {\n            reply.println(\"As there were validation problems, no additional issues will be added to the list of solved issues.\");\n            return;\n        }\n\n        var titleIssue = Issue.fromStringRelaxed(pr.title());\n        for (var issue : validatedIssues) {\n            if (titleIssue.isEmpty()) {\n                reply.print(\"The primary solved issue for a PR is set through the PR title. Since the current title does \");\n                reply.println(\"not contain an issue reference, it will now be updated.\");\n                pr.setTitle(issue.toShortString());\n                titleIssue = Optional.of(issue);\n                continue;\n            }\n            if (titleIssue.get().shortId().equals(issue.shortId())) {\n                reply.println(\"This issue is referenced in the PR title - it will now be updated.\");\n                pr.setTitle(issue.toShortString());\n                continue;\n            }\n            reply.println(SolvesTracker.setSolvesMarker(issue));\n            if (currentSolved.contains(issue.shortId())) {\n                reply.println(\"Updating description of additional solved issue: `\" + issue.toShortString() + \"`.\");\n            } else {\n                reply.println(\"Adding additional issue to \" + name + \" list: `\" + issue.toShortString() + \"`.\");\n            }\n        }\n    }\n\n    private void removeIssue(PullRequestBot bot, String args, Set<String> currentSolved, PrintWriter reply) throws InvalidIssue {\n        var issueListStart = args.indexOf(' ');\n        if (issueListStart == -1) {\n            showHelp(reply);\n            return;\n        }\n        if (currentSolved.isEmpty()) {\n            reply.println(\"This PR does not contain any additional solved issues that can be removed. To remove the primary solved issue, simply edit the title of this PR.\");\n            return;\n        }\n        var issuesToRemove = parseIssueList(bot.issueProject() == null ? \"\" : bot.issueProject().name(), args.substring(issueListStart));\n        for (var issue : issuesToRemove) {\n            if (currentSolved.contains(issue.shortId())) {\n                reply.println(SolvesTracker.removeSolvesMarker(issue));\n                reply.println(\"Removing additional issue from \" + name + \" list: `\" + issue.shortId() + \"`.\");\n            } else {\n                reply.print(\"The issue `\" + issue.shortId() + \"` was not found in the list of additional solved issues. The list currently contains these issues: \");\n                var currentList = currentSolved.stream()\n                                               .map(id -> \"`\" + id + \"`\")\n                                               .collect(Collectors.joining(\", \"));\n                reply.println(currentList);\n            }\n        }\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        if (!command.user().equals(pr.author())) {\n            reply.println(\"Only the author (@\" + pr.author().username() + \") is allowed to issue the `/\" + name + \"` command.\");\n            return;\n        }\n        var args = command.args();\n        if (args.isBlank()) {\n            showHelp(reply);\n            return;\n        }\n        var subCommandMatcher = SUBCOMMAND_PATTERN.matcher(args);\n        if (!subCommandMatcher.matches()) {\n            showHelp(reply);\n            return;\n        }\n        var currentSolved = SolvesTracker.currentSolved(pr.repository().forge().currentUser(), allComments, pr.title())\n                                         .stream()\n                                         .map(Issue::shortId)\n                                         .collect(Collectors.toSet());\n        try {\n            if (args.startsWith(\"remove\") || args.startsWith(\"delete\")) {\n                removeIssue(bot, args, currentSolved, reply);\n            } else {\n                addIssue(bot, pr, args, currentSolved, reply);\n            }\n        } catch (InvalidIssue invalidIssue) {\n            reply.println(\"The issue identifier `\" + invalidIssue.identifier() + \"` is invalid: \" + invalidIssue.reason() + \".\");\n        }\n    }\n\n    @Override\n    public String description() {\n        return \"edit the list of issues that this PR solves\";\n    }\n\n    @Override\n    public String name() {\n        return issue.name();\n    }\n\n    @Override\n    public boolean allowedInBody() {\n        return true;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/JEPCommand.java",
    "content": "/*\n * Copyright (c) 2022, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.io.PrintWriter;\nimport java.util.List;\nimport java.util.Optional;\nimport org.openjdk.skara.issuetracker.IssueTrackerIssue;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.jep;\nimport static org.openjdk.skara.issuetracker.jira.JiraProject.JEP_NUMBER;\nimport static org.openjdk.skara.bots.common.PullRequestConstants.*;\n\npublic class JEPCommand implements CommandHandler {\n    private static final String UNNEEDED_MARKER = \"<!-- jep: 'unneeded' 'unneeded' 'unneeded' -->\";\n\n    private void showHelp(PrintWriter reply) {\n        reply.println(\"\"\"\n                Command syntax:\n                 * `/jep <jep-id>|<issue-id>`\n                 * `/jep JEP-<jep-id>`\n                 * `/jep jep-<jep-id>`\n                 * `/jep unneeded`\n\n                Some examples:\n                 * `/jep 123`\n                 * `/jep JDK-1234567`\n                 * `/jep 1234567`\n                 * `/jep jep-123`\n                 * `/jep JEP-123`\n                 * `/jep unneeded`\n\n                Note:\n                The prefix (i.e. `JDK-`, `JEP-` or `jep-`) is optional. If the argument is given without prefix, \\\n                it will be tried first as a JEP ID and second as an issue ID. The issue type must be `JEP`.\n                \"\"\");\n    }\n\n    private Optional<IssueTrackerIssue> getJepIssue(String args, PullRequestBot bot) {\n        Optional<IssueTrackerIssue> jbsIssue = Optional.empty();\n        var upperArgs = args.toUpperCase();\n        if (upperArgs.startsWith(\"JEP-\")) {\n            // Handle the JEP ID with `JEP` prefix\n            jbsIssue = bot.issueProject().jepIssue(args.substring(4));\n        } else {\n            if (!upperArgs.startsWith(bot.issueProject().name().toUpperCase())) {\n                // Handle the raw JEP ID without `JEP` prefix and project prefix. If the JEP has the same ID\n                // as any issue, the bot firstly parse the ID as JEP instead of general issue.\n                // For example, if we have a `JEP-12345` (its issue ID is not `JDK-12345`) and an issue `JDK-12345`,\n                // when typing `/jep 12345`, the bot firstly parses it as `JEP-12345` instead of `JDK-12345`.\n                jbsIssue = bot.issueProject().jepIssue(args);\n            }\n            if (jbsIssue.isEmpty()) {\n                // Handle the issue ID\n                jbsIssue = bot.issueProject().issue(args);\n            }\n        }\n        return jbsIssue;\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command,\n                       List<Comment> allComments, PrintWriter reply, List<String> labelsToAdd, List<String> labelsToRemove) {\n        if (!bot.enableJep()) {\n            reply.println(\"This repository has not been configured to use the `jep` command.\");\n            return;\n        }\n\n        if (!pr.author().equals(command.user()) && !censusInstance.isReviewer(command.user())) {\n            reply.println(\"Only the pull request author and [Reviewers](https://openjdk.org/bylaws#reviewer) are allowed to use the `jep` command.\");\n            return;\n        }\n\n        var args = command.args().trim();\n        if (args.isEmpty() || args.isBlank()) {\n            showHelp(reply);\n            return;\n        }\n\n        var labelNames = pr.labelNames();\n        if (\"unneeded\".equals(args) || \"uneeded\".equals(args)) {\n            if (labelNames.contains(JEP_LABEL)) {\n                labelsToRemove.add(JEP_LABEL);\n            }\n            reply.println(UNNEEDED_MARKER);\n            reply.println(\"determined that the JEP request is not needed for this pull request.\");\n            return;\n        }\n\n        // Get the issue\n        var jbsIssueOpt = getJepIssue(args, bot);\n        if (jbsIssueOpt.isEmpty()) {\n            reply.println(\"The JEP issue was not found. Please make sure you have entered it correctly.\");\n            showHelp(reply);\n            return;\n        }\n        var jbsIssue = jbsIssueOpt.get();\n\n        // Verify whether the issue type is a JEP\n        var issueType = jbsIssue.properties().get(\"issuetype\");\n        if (issueType == null || !\"JEP\".equals(issueType.asString())) {\n            reply.println(\"The issue `\" + jbsIssue.id() + \"` is not a JEP. Please make sure you have entered it correctly.\");\n            showHelp(reply);\n            return;\n        }\n\n        // Get the issue status\n        var issueStatus = jbsIssue.status();\n        var resolution = jbsIssue.resolution();\n\n        // Set the marker and output the result\n        var jepNumber = jbsIssue.properties().containsKey(JEP_NUMBER) ? jbsIssue.properties().get(JEP_NUMBER).asString() : \"NotAllocated\";\n        reply.println(String.format(JEP_MARKER, jepNumber, jbsIssue.id(), jbsIssue.title()));\n        if (\"Targeted\".equals(issueStatus) || \"Integrated\".equals(issueStatus) ||\n                \"Completed\".equals(issueStatus) || (\"Closed\".equals(issueStatus) && resolution.isPresent() && \"Delivered\".equals(resolution.get()))) {\n            reply.println(\"The JEP for this pull request, [JEP-\" + jepNumber + \"](\" + jbsIssue.webUrl() + \"), has already been targeted.\");\n            if (labelNames.contains(JEP_LABEL)) {\n                labelsToRemove.add(JEP_LABEL);\n            }\n        } else {\n            // The current issue status may be \"Draft\", \"Submitted\", \"Candidate\", \"Proposed to Target\", \"Proposed to Drop\" or \"Closed without Delivered\"\n            if (jepNumber.equals(\"NotAllocated\")) {\n                reply.println(\"This pull request will not be integrated until the [JEP \" + jbsIssue.id()\n                        + \"](\" + jbsIssue.webUrl() + \")\" + \" has been targeted.\");\n            } else {\n                reply.println(\"This pull request will not be integrated until the [JEP-\" + jepNumber\n                        + \"](\" + jbsIssue.webUrl() + \")\" + \" has been targeted.\");\n            }\n            if (!labelNames.contains(JEP_LABEL)) {\n                labelsToAdd.add(JEP_LABEL);\n            }\n        }\n    }\n\n    @Override\n    public String description() {\n        return \"require a JDK Enhancement Proposal (JEP) for this pull request\";\n    }\n\n    @Override\n    public String name() {\n        return jep.name();\n    }\n\n    @Override\n    public boolean allowedInBody() {\n        return true;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/LabelCommand.java",
    "content": "/*\n * Copyright (c) 2020, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.io.*;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.label;\n\npublic class LabelCommand implements CommandHandler {\n    private final String commandName;\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n    private static final Pattern ARGUMENT_PATTERN = Pattern.compile(\"(?:(add|remove)\\\\s+)((?:[A-Za-z0-9_@.-]+[\\\\s,]*)+)\");\n    private static final Pattern SHORT_ARGUMENT_PATTERN = Pattern.compile(\"((?:[-+]?[A-Za-z0-9_@.-]+[\\\\s,]*)+)\");\n    private static final Pattern IGNORED_SUFFIXES = Pattern.compile(\"^(.*)(?:-dev(?:@openjdk.org)?)$\");\n\n    LabelCommand() {\n        this(\"label\");\n    }\n\n    LabelCommand(String commandName) {\n        this.commandName = commandName;\n    }\n\n    private void showHelp(LabelConfiguration labelConfiguration, PrintWriter reply) {\n        reply.println(\"Usage: `/\" + commandName + \" <add|remove> [label[, label, ...]]` \" +\n                      \"or `/\" + commandName + \" [<+|->label[, <+|->label, ...]]` \" +\n                      \"where `label` is an additional classification that should \" +\n                      \"be applied to this PR. These labels are valid:\");\n        labelConfiguration.allowed().forEach(label -> reply.println(\" * `\" + label + \"`\"));\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        if (!command.user().equals(pr.author()) && (!censusInstance.isCommitter(command.user()))) {\n            reply.println(\"Only the PR author and project [Committers](https://openjdk.org/bylaws#committer) are allowed to modify labels on a PR.\");\n            return;\n        }\n\n        var argumentMatcher = ARGUMENT_PATTERN.matcher(command.args());\n        var shortArgumentMatcher = SHORT_ARGUMENT_PATTERN.matcher(command.args());\n        if (!argumentMatcher.matches() && !shortArgumentMatcher.matches()) {\n            showHelp(bot.labelConfiguration(), reply);\n            return;\n        }\n        var currentLabels = new HashSet<>(pr.labelNames());\n\n        if (argumentMatcher.matches()) {\n            var labels = Arrays.stream(argumentMatcher.group(2).split(\"[\\\\s,]+\")).collect(Collectors.toList());\n            if (labels.size() == 0) {\n                showHelp(bot.labelConfiguration(), reply);\n                return;\n            }\n            var invalidLabels = verifyLabels(labels, bot);\n            if (!invalidLabels.isEmpty()) {\n                printInvalidLabels(invalidLabels, bot, reply);\n                return;\n            }\n            if (argumentMatcher.group(1).equals(\"add\")) {\n                addLabels(labels, currentLabels, pr, reply, bot);\n            } else if (argumentMatcher.group(1).equals(\"remove\")) {\n                removeLabels(labels, currentLabels, pr, reply);\n            }\n            return;\n        }\n\n        if (shortArgumentMatcher.matches()) {\n            var labels = Arrays.stream(shortArgumentMatcher.group(1).split(\"[\\\\s,]+\")).collect(Collectors.toList());\n            if (labels.size() == 0 || \"add\".equals(labels.get(0)) || \"remove\".equals(labels.get(0))) {\n                // The comparison of the `add and `remove` is to solve this situation: `/label add +label1, -label2`.\n                showHelp(bot.labelConfiguration(), reply);\n                return;\n            }\n            var labelsToAdd = new ArrayList<String>();\n            var labelsToRemove = new ArrayList<String>();\n            labels.forEach(label -> {\n                if (label.startsWith(\"-\")) {\n                    labelsToRemove.add(label.substring(1).strip());\n                } else if (label.startsWith(\"+\")){\n                    labelsToAdd.add(label.substring(1).strip());\n                } else {\n                    labelsToAdd.add(label.strip());\n                }\n            });\n\n            var invalidLabels = verifyLabels(labelsToAdd, bot);\n            invalidLabels.addAll(verifyLabels(labelsToRemove, bot));\n            if (!invalidLabels.isEmpty()) {\n                printInvalidLabels(invalidLabels, bot, reply);\n                return;\n            }\n\n            addLabels(labelsToAdd, currentLabels, pr, reply, bot);\n            removeLabels(labelsToRemove, currentLabels, pr, reply);\n        }\n    }\n\n    private void printInvalidLabels(List<String> invalidLabels, PullRequestBot bot, PrintWriter reply) {\n        reply.println(\"\"); // Intentionally blank line.\n        invalidLabels.forEach(label -> reply.println(\"The label `\" + label + \"` is not a valid label.\"));\n        reply.println(\"These labels are valid:\");\n        bot.labelConfiguration().allowed().forEach(l -> reply.println(\" * `\" + l + \"`\"));\n    }\n\n    /**\n     * Verify whether the labels are valid, return the invalid labels.\n     */\n    private List<String> verifyLabels(List<String> labels, PullRequestBot bot) {\n        List<String> invalidLabels = new ArrayList<>();\n        for (int i = 0; i < labels.size(); ++i) {\n            var label = labels.get(i);\n            var ignoredSuffixMatcher = IGNORED_SUFFIXES.matcher(label);\n            if (ignoredSuffixMatcher.matches()) {\n                label = ignoredSuffixMatcher.group(1);\n                labels.set(i, label);\n            }\n            if (!bot.labelConfiguration().allowed().contains(label)) {\n                invalidLabels.add(label);\n            }\n        }\n        return invalidLabels;\n    }\n\n    /**\n     * Attempts to add each label in labelsToAdd to the pull request.\n     * Updates to currentLabels are performed immediately after each label addition.\n     */\n    private void addLabels(List<String> labelsToAdd, Set<String> currentLabels, PullRequest pr, PrintWriter reply, PullRequestBot bot) {\n        for (var label : labelsToAdd) {\n            if (!currentLabels.contains(label)) {\n                pr.addLabel(label);\n                currentLabels.add(label);\n                reply.println(LabelTracker.addLabelMarker(label));\n                reply.println(\"The `\" + label + \"` label was successfully added.\");\n            } else {\n                reply.println(\"The `\" + label + \"` label was already applied.\");\n            }\n        }\n    }\n\n    /**\n     * Attempts to remove each label in labelsToRemove from the pull request.\n     * Updates to currentLabels are performed immediately after each label removal.\n     */\n    private void removeLabels(List<String> labelsToRemove,Set<String> currentLabels, PullRequest pr, PrintWriter reply) {\n        for (var label : labelsToRemove) {\n            if (currentLabels.contains(label)) {\n                pr.removeLabel(label);\n                currentLabels.remove(label);\n                reply.println(LabelTracker.removeLabelMarker(label));\n                reply.println(\"The `\" + label + \"` label was successfully removed.\");\n            } else {\n                reply.println(\"The `\" + label + \"` label was not set.\");\n            }\n        }\n    }\n\n    @Override\n    public String description() {\n        return \"add or remove an additional classification label\";\n    }\n\n    @Override\n    public String name() {\n        return label.name();\n    }\n\n    @Override\n    public boolean allowedInBody() {\n        return true;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/LabelTracker.java",
    "content": "/*\n * Copyright (c) 2020, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.util.*;\nimport java.util.regex.*;\nimport java.util.stream.Collectors;\n\npublic class LabelTracker {\n    private static final String ADD_MARKER = \"<!-- added label: '%s' -->\";\n    private static final String REMOVE_MARKER = \"<!-- removed label: '%s' -->\";\n    private static final Pattern LABEL_MARKER_PATTERN = Pattern.compile(\"<!-- (added|removed) label: '(.*?)' -->\");\n\n    static String addLabelMarker(String label) {\n        return String.format(ADD_MARKER, label);\n    }\n\n    static String removeLabelMarker(String label) {\n        return String.format(REMOVE_MARKER, label);\n    }\n\n    // Return all manually added labels, but filter any explicitly removed ones\n    static Set<String> currentAdded(HostUser botUser, List<Comment> comments) {\n        var labelActions = comments.stream()\n                .filter(comment -> comment.author().equals(botUser))\n                .flatMap(comment -> comment.body().lines())\n                .map(LABEL_MARKER_PATTERN::matcher)\n                .filter(Matcher::find)\n                .collect(Collectors.toList());\n\n        var ret = new HashSet<String>();\n        for (var actionMatch : labelActions) {\n            var action = actionMatch.group(1);\n            if (action.equals(\"added\")) {\n                ret.add(actionMatch.group(2));\n            } else {\n                ret.remove(actionMatch.group(2));\n            }\n        }\n\n        return Collections.unmodifiableSet(ret);\n    }\n\n    // Return all manually removed labels, but filter any explicitly added ones\n    static Set<String> currentRemoved(HostUser botUser, List<Comment> comments) {\n        var labelActions = comments.stream()\n                                   .filter(comment -> comment.author().equals(botUser))\n                                   .flatMap(comment -> comment.body().lines())\n                                   .map(LABEL_MARKER_PATTERN::matcher)\n                                   .filter(Matcher::find)\n                                   .collect(Collectors.toList());\n\n        var ret = new HashSet<String>();\n        for (var actionMatch : labelActions) {\n            var action = actionMatch.group(1);\n            if (action.equals(\"removed\")) {\n                ret.add(actionMatch.group(2));\n            } else {\n                ret.remove(actionMatch.group(2));\n            }\n        }\n\n        return Collections.unmodifiableSet(ret);\n    }\n\n    // Return true if the latest operation on the given label by the botUser was \"removed\"\n    static boolean isLabelManuallyRemoved(HostUser botUser, List<Comment> comments, String label) {\n        return comments.stream()\n                .filter(comment -> comment.author().equals(botUser))\n                .sorted(Comparator.comparing(Comment::createdAt).reversed())\n                .flatMap(comment -> comment.body().lines())\n                .map(LABEL_MARKER_PATTERN::matcher)\n                .filter(Matcher::find)\n                .filter(matcher -> matcher.group(2).equals(label))\n                .map(matcher -> matcher.group(1))\n                .findFirst()\n                .map(action -> action.equals(\"removed\"))\n                .orElse(false);\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/LabelerWorkItem.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport java.time.ZonedDateTime;\n\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.Repository;\n\nimport java.io.*;\nimport java.util.*;\nimport java.util.function.Consumer;\nimport java.util.logging.Logger;\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\nimport static org.openjdk.skara.bots.pr.CheckRun.findComment;\nimport static org.openjdk.skara.bots.pr.CheckRun.syncLabels;\n\npublic class LabelerWorkItem extends PullRequestWorkItem {\n    protected static final String INITIAL_LABEL_MESSAGE = \"<!-- PullRequestBot initial label help comment -->\";\n    private static final String LABEL_COMMIT_MARKER = \"<!-- PullRequest Bot label commit '%s' -->\";\n    protected static final Pattern LABEL_COMMIT_PATTERN = Pattern.compile(\"<!-- PullRequest Bot label commit '(.*?)' -->\");\n    private static final String AUTO_LABEL_ADDITIONAL_COMMENT_MARKER = \"<!-- PullRequest Bot auto label additional comment '%s' -->\";\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n\n    LabelerWorkItem(PullRequestBot bot, String prId, Consumer<RuntimeException> errorHandler,\n                    ZonedDateTime prUpdatedAt) {\n        super(bot, prId, errorHandler, prUpdatedAt, false);\n    }\n\n    @Override\n    public String toString() {\n        return \"LabelerWorkItem@\" + bot.repo().name() + \"#\" + prId;\n    }\n\n    private Set<String> getLabels(Repository localRepo) throws IOException {\n        var files = PullRequestUtils.changedFiles(pr, localRepo);\n        return bot.labelConfiguration().label(files);\n    }\n\n    private void createInitialLabelMessage(List<Comment> comments, List<String> newLabels, String commitHash) {\n        var existing = findComment(comments, INITIAL_LABEL_MESSAGE, pr);\n        if (existing.isPresent()) {\n            // Only add the comment once per PR\n            return;\n        }\n\n        var message = new StringBuilder();\n        message.append(\"@\");\n        message.append(pr.author().username());\n        message.append(\" \");\n\n        if (newLabels.isEmpty()) {\n            message.append(\"To determine the appropriate audience for reviewing this pull request, one or more \");\n            message.append(\"labels corresponding to different subsystems will normally be applied automatically. \");\n            message.append(\"However, no automatic labelling rule matches the changes in this pull request. \");\n            message.append(\"In order to have an \\\"RFR\\\" email sent to the correct mailing list, you will \");\n            message.append(\"need to add one or more applicable labels manually using the \");\n            message.append(\"[/label](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands#PullRequestCommands-/label)\");\n            message.append(\" pull request command.\\n\\n\");\n            message.append(\"<details>\\n\");\n            message.append(\"<summary>Applicable Labels</summary>\\n\");\n            message.append(\"<br>\\n\");\n            message.append(\"\\n\");\n            bot.labelConfiguration().allowed()\n                    .stream()\n                    .sorted()\n                    .forEach(label -> message.append(\"- `\" + label + \"`\\n\"));\n            message.append(\"\\n\");\n            message.append(\"</details>\");\n        } else {\n            message.append(\"The following label\");\n            if (newLabels.size() > 1) {\n                message.append(\"s\");\n            }\n            message.append(\" will be automatically applied to this pull request:\\n\\n\");\n            newLabels.stream()\n                    .sorted()\n                    .forEach(label -> message.append(\"- `\" + label + \"`\\n\"));\n            message.append(\"\\n\");\n            message.append(\"When this pull request is ready to be reviewed, an \\\"RFR\\\" email will be sent to the \");\n            message.append(\"corresponding mailing list\");\n            if (newLabels.size() > 1) {\n                message.append(\"s\");\n            }\n            message.append(\". If you would like to change these labels, use the \");\n            message.append(\"[/label](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands#PullRequestCommands-/label)\");\n            message.append(\" pull request command.\");\n        }\n\n        message.append(\"\\n\");\n        message.append(INITIAL_LABEL_MESSAGE);\n        message.append(\"\\n\");\n        message.append(String.format(LABEL_COMMIT_MARKER, commitHash));\n        pr.addComment(message.toString());\n    }\n\n    @Override\n    public Collection<WorkItem> prRun(ScratchArea scratchArea) {\n        // If the pr is already closed, return early\n        if (pr.isClosed()) {\n            return List.of();\n        }\n\n        // If no label configuration, return early\n        if (bot.labelConfiguration().allowed().isEmpty()) {\n            return List.of();\n        }\n\n        var comments = prComments();\n        var initialLabelComment = findComment(comments, INITIAL_LABEL_MESSAGE, pr);\n        Set<String> oldLabels = new HashSet<>(pr.labelNames());\n        Set<String> newLabels = new HashSet<>(pr.labelNames());\n\n        // If the initial label comment can be found, updating labels when new files are touched\n        if (initialLabelComment.isPresent()) {\n            try {\n                var localRepo = IntegrateCommand.materializeLocalRepo(bot, pr, scratchArea);\n                var autoLabeledHashOpt = autoLabeledHash(comments, pr);\n                if (autoLabeledHashOpt.isPresent()) {\n                    var evaluatedCommitHash = autoLabeledHashOpt.get();\n                    var changedFiles = PullRequestUtils.changedFiles(pr, localRepo, new Hash(evaluatedCommitHash));\n                    var newLabelsNeedToBeAdded = bot.labelConfiguration().label(changedFiles);\n\n                    processLabelsWithGroups(comments, oldLabels, newLabels, newLabelsNeedToBeAdded);\n                    // The labels actually added\n                    newLabels.removeAll(oldLabels);\n                    if (!newLabels.isEmpty()) {\n                        addLabelAutoUpdateAdditionalComment(comments, new ArrayList<>(newLabels), pr.headHash().hex());\n                    }\n\n                    Matcher matcher = LABEL_COMMIT_PATTERN.matcher(initialLabelComment.get().body());\n                    String updatedBody = matcher.replaceAll(String.format(LABEL_COMMIT_MARKER, pr.headHash().toString()));\n                    pr.updateComment(initialLabelComment.get().id(), updatedBody);\n                } else {\n                    // If auto label comment is present but auto label hash isn't present, mark the headHash as handled.\n                    pr.updateComment(initialLabelComment.get().id(),\n                            initialLabelComment.get().body() + \"\\n\" + String.format(LABEL_COMMIT_MARKER, pr.headHash().toString()));\n                }\n            } catch (IOException e) {\n                throw new UncheckedIOException(e);\n            }\n            // No need to return CheckWorkItem, if there is any label added, in the next round of CheckWorkItem, it will re-evaluate the pr\n            return List.of();\n        } else {\n            // Initial auto labeling\n            try {\n                var localRepo = IntegrateCommand.materializeLocalRepo(bot, pr, scratchArea);\n                var newLabelsNeedToBeAdded = getLabels(localRepo);\n                processLabelsWithGroups(comments, oldLabels, newLabels, newLabelsNeedToBeAdded);\n                // The labels actually added\n                newLabels.removeAll(oldLabels);\n                createInitialLabelMessage(comments, new ArrayList<>(newLabels), pr.headHash().toString());\n            } catch (IOException e) {\n                throw new UncheckedIOException(e);\n            }\n            return needsRfrCheck(newLabels);\n        }\n    }\n\n\n    /**\n     * For each label needing to be added, check if an associated group label should be set.\n     * If no group label applies, just add the original label.\n     */\n    private void processLabelsWithGroups(List<Comment> comments, Set<String> oldLabels, Set<String> newLabels, Set<String> newLabelsNeedToBeAdded) {\n        for (var label : newLabelsNeedToBeAdded) {\n            var groupLabels = bot.labelConfiguration().groupLabels(label);\n            if (groupLabels.isEmpty()) {\n                // If the label doesn't belong to any group, just add it.\n                newLabels.add(label);\n            } else {\n                for (var groupLabel : groupLabels) {\n                    // If old Labels already have this group label, skip adding this label or group label\n                    if (oldLabels.contains(groupLabel)) {\n                        continue;\n                    }\n                    // Check if this group label was manually removed by the user.\n                    if (LabelTracker.isLabelManuallyRemoved(pr.repository().forge().currentUser(), comments, groupLabel)) {\n                        // If the group was manually removed by user, don't upgrade this label to group label\n                        newLabels.add(label);\n                    } else {\n                        // See if any existing label belongs to the same group (excluding the current label).\n                        boolean hasOtherLabelInGroup = bot.labelConfiguration()\n                                .labelsInGroup(groupLabel)\n                                .stream()\n                                .filter(l -> !l.equals(label))\n                                .anyMatch(oldLabels::contains);\n                        if (hasOtherLabelInGroup) {\n                            // Upgrade: since another group label exists, we can add the group label itself.\n                            newLabels.add(groupLabel);\n                        } else {\n                            // No other group label found, so add the original label.\n                            newLabels.add(label);\n                        }\n                    }\n                }\n            }\n        }\n        syncLabels(pr, oldLabels, newLabels, log);\n    }\n\n    void addLabelAutoUpdateAdditionalComment(List<Comment> comments, List<String> labelsAdded, String commitHash) {\n        if (findComment(comments, String.format(AUTO_LABEL_ADDITIONAL_COMMENT_MARKER, commitHash), pr).isPresent()) {\n            // Only add the comment once\n            return;\n        }\n        var message = new StringBuilder();\n        message.append(\"@\");\n        message.append(pr.author().username());\n        message.append(\" \");\n        if (!labelsAdded.isEmpty()) {\n            Collections.sort(labelsAdded);\n            message.append(labelsAdded.stream()\n                    .map(label -> \"`\" + label + \"`\")\n                    .collect(Collectors.joining(\", \")));\n            message.append(labelsAdded.size() == 1 ? \" has\" : \" have\");\n            message.append(\" been added to this pull request based on files touched in new commit(s).\");\n        }\n        message.append(\"\\n\");\n        message.append(String.format(AUTO_LABEL_ADDITIONAL_COMMENT_MARKER, commitHash));\n        pr.addComment(message.toString());\n    }\n\n    static Optional<String> autoLabeledHash(List<Comment> comments, PullRequest pr) {\n        var labelComment = findComment(comments, INITIAL_LABEL_MESSAGE, pr);\n        if (labelComment.isPresent()) {\n            var line = labelComment.get().body().lines()\n                    .map(LABEL_COMMIT_PATTERN::matcher)\n                    .filter(Matcher::find)\n                    .findFirst();\n            if (line.isPresent()) {\n                return Optional.of(line.get().group(1));\n            }\n        }\n        return Optional.empty();\n    }\n\n    private Collection<WorkItem> needsRfrCheck(Set<String> labelNames) {\n        if (!labelNames.contains(\"rfr\")) {\n            return List.of(CheckWorkItem.fromWorkItemWithForceUpdate(bot, prId, errorHandler, triggerUpdatedAt));\n        }\n        return List.of();\n    }\n\n    @Override\n    public String workItemName() {\n        return \"labeler\";\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/LimitedCensusInstance.java",
    "content": "/*\n * Copyright (c) 2022, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.net.URLEncoder;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Path;\nimport java.util.Arrays;\nimport java.util.Optional;\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.census.Contributor;\nimport org.openjdk.skara.census.Namespace;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.forge.HostedRepositoryPool;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.jcheck.JCheckConfiguration;\nimport org.openjdk.skara.network.UncheckedRestException;\n\nclass MissingJCheckConfException extends Exception {\n    public MissingJCheckConfException() {\n    }\n}\n\nclass InvalidJCheckConfException extends Exception {\n    public InvalidJCheckConfException(Throwable cause) {\n        super(cause);\n    }\n}\n/**\n * The LimitedCensusInstance does not have a Project. Use this when the project\n * may be invalid or unavailable to avoid errors, otherwise use CensusInstance\n */\nclass LimitedCensusInstance {\n\n    protected final Census census;\n    protected final JCheckConfiguration configuration;\n    protected final Namespace namespace;\n\n    LimitedCensusInstance(Census census, JCheckConfiguration configuration, Namespace namespace) {\n        this.census = census;\n        this.configuration = configuration;\n        this.namespace = namespace;\n    }\n\n    static LimitedCensusInstance createLimitedCensusInstance(HostedRepositoryPool hostedRepositoryPool,\n            HostedRepository censusRepo, String censusRef, Path folder, HostedRepository repository, String ref,\n            HostedRepository confOverrideRepo, String confOverrideName, String confOverrideRef) throws MissingJCheckConfException, InvalidJCheckConfException {\n        Path repoFolder = getRepoFolder(hostedRepositoryPool, censusRepo, censusRef, folder);\n\n        try {\n            JCheckConfiguration configuration = jCheckConfiguration(repository, ref, confOverrideRepo,\n                    confOverrideName, confOverrideRef).orElseThrow(MissingJCheckConfException::new);\n            var census = Census.parse(repoFolder);\n            var namespace = namespace(census, repository.namespace());\n            return new LimitedCensusInstance(census, configuration, namespace);\n        } catch (IOException e) {\n            throw new UncheckedIOException(\"Cannot parse census at \" + repoFolder, e);\n        }\n    }\n\n    private static Namespace namespace(Census census, String hostNamespace) {\n        //var namespace = census.namespace(pr.repository().getNamespace());\n        var namespace = census.namespace(hostNamespace);\n        if (namespace == null) {\n            throw new RuntimeException(\"Namespace not found in census: \" + hostNamespace);\n        }\n        return namespace;\n    }\n\n    private static Optional<JCheckConfiguration> configuration(HostedRepository remoteRepo, String name, String ref) {\n        return remoteRepo.fileContents(name, ref).map(contents -> JCheckConfiguration.parse(Arrays.stream(contents.split(\"\\n\")).toList()));\n    }\n\n    private static Optional<JCheckConfiguration> jCheckConfiguration(\n            HostedRepository repository, String ref, HostedRepository confOverrideRepo, String confOverrideName,\n            String confOverrideRef) throws IOException, InvalidJCheckConfException {\n        Optional<JCheckConfiguration> configuration;\n        try {\n            if (confOverrideRepo == null) {\n                configuration = configuration(repository, \".jcheck/conf\", ref);\n            } else {\n                configuration = configuration(confOverrideRepo,\n                        confOverrideName,\n                        confOverrideRef);\n            }\n            return configuration;\n        } catch (UncheckedRestException | UncheckedIOException e) {\n            throw e;\n        } catch (RuntimeException e) {\n            throw new InvalidJCheckConfException(e);\n        }\n    }\n\n    private static Path getRepoFolder(HostedRepositoryPool hostedRepositoryPool, HostedRepository censusRepo, String censusRef, Path folder) {\n        var repoName = censusRepo.url().getHost() + \"/\" + censusRepo.name();\n        var repoFolder = folder.resolve(URLEncoder.encode(repoName, StandardCharsets.UTF_8));\n        try {\n            hostedRepositoryPool.checkoutAllowStale(censusRepo, censusRef, repoFolder);\n        } catch (IOException e) {\n            throw new UncheckedIOException(\"Cannot materialize census to \" + repoFolder, e);\n        }\n        return repoFolder;\n    }\n\n    Optional<Contributor> contributor(HostUser hostUser) {\n        var contributor = namespace.get(hostUser.id());\n        return Optional.ofNullable(contributor);\n    }\n\n    Census census() {\n        return census;\n    }\n\n    JCheckConfiguration configuration() {\n        return configuration;\n    }\n\n    Namespace namespace() {\n        return namespace;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/MergePullRequestReviewConfiguration.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nenum MergePullRequestReviewConfiguration {\n    ALWAYS,\n    NEVER,\n    JCHECK\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/OpenCommand.java",
    "content": "/*\n * Copyright (c) 2021, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.network.UncheckedRestException;\n\nimport java.io.PrintWriter;\nimport java.util.List;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.open;\n\npublic class OpenCommand implements CommandHandler {\n    private void showHelp(PrintWriter reply) {\n        reply.println(\"Usage: `/open`\");\n    }\n\n    @Override\n    public String description() {\n        return \"Set the pull request state to \\\"open\\\"\";\n    }\n\n    @Override\n    public String name() {\n        return open.name();\n    }\n\n    @Override\n    public boolean allowedInBody() {\n        return false;\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply)\n    {\n        if (!command.user().equals(pr.author())) {\n            reply.println(\"Only the pull request author can set the pull request state to \\\"open\\\"\");\n            return;\n        }\n\n        if (pr.isOpen()) {\n            reply.println(\"This pull request is already open\");\n            return;\n\n        }\n        try {\n            pr.setState(Issue.State.OPEN);\n        } catch (UncheckedRestException e) {\n            if (e.getStatusCode() == 422) {\n                reply.println(\"Validation failed: this pull request can't be reopened. \" +\n                        \"The source branch may have been force-pushed or recreated.\");\n                return;\n            } else {\n                throw e;\n            }\n        }\n        reply.println(\"This pull request is now open\");\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/OverridingAuthor.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.util.*;\nimport java.util.regex.*;\n\nclass OverridingAuthor {\n    private static final String SET_MARKER = \"<!-- set author: '%s' -->\";\n    private static final String REMOVE_MARKER = \"<!-- remove author: '%s' -->\";\n    private static final Pattern MARKER_PATTERN = Pattern.compile(\"<!-- (set|remove) author: '(.*?)' -->\");\n\n    static String setAuthorMarker(EmailAddress author) {\n        return String.format(SET_MARKER, author.toString());\n    }\n\n    static String removeAuthorMarker(EmailAddress author) {\n        return String.format(REMOVE_MARKER, author.toString());\n    }\n\n    static Optional<EmailAddress> author(HostUser botUser, List<Comment> comments) {\n        var authorActions = comments.stream()\n                .filter(comment -> comment.author().equals(botUser))\n                .map(comment -> MARKER_PATTERN.matcher(comment.body()))\n                .filter(Matcher::find)\n                .toList();\n        Optional<EmailAddress> author = Optional.empty();\n        for (var action : authorActions) {\n            switch (action.group(1)) {\n                case \"set\":\n                    author = Optional.of(EmailAddress.parse(action.group(2)));\n                    break;\n                case \"remove\":\n                    author = Optional.empty();\n                    break;\n            }\n        }\n\n        return author;\n    }\n}\n\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/PRRecord.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\npublic record PRRecord(String repoName, String prId) {\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestBot.java",
    "content": "/*\n * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.census.Contributor;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.JSONValue;\nimport org.openjdk.skara.network.UncheckedRestException;\n\nimport java.net.URI;\nimport java.nio.file.Path;\nimport java.time.*;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.logging.*;\nimport java.util.regex.Pattern;\n\nclass PullRequestBot implements Bot {\n    private final HostedRepository remoteRepo;\n    private final HostedRepository censusRepo;\n    private final String censusRef;\n    private final LabelConfiguration labelConfiguration;\n    private final Map<String, String> externalPullRequestCommands;\n    private final Map<String, String> externalCommitCommands;\n    private final Map<String, String> blockingCheckLabels;\n    private final Set<String> readyLabels;\n    private final Set<String> twoReviewersLabels;\n    private final Set<String> twentyFourHoursLabels;\n    private final Map<String, Pattern> readyComments;\n    private final IssueProject issueProject;\n    private final boolean useStaleReviews;\n    private final boolean acceptSimpleMerges;\n    private final Pattern allowedTargetBranches;\n    private final Path seedStorage;\n    private final HostedRepository confOverrideRepo;\n    private final String confOverrideName;\n    private final String confOverrideRef;\n    private final String censusLink;\n    private final Map<String, HostedRepository> forks;\n    private final Set<String> integrators;\n    private final Set<Integer> excludeCommitCommentsFrom;\n    private final boolean enableCsr;\n    private final boolean enableJep;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n    private final PullRequestPoller poller;\n    private final boolean reviewCleanBackport;\n    private final String mlbridgeBotName;\n    private final MergePullRequestReviewConfiguration reviewMerge;\n    private final boolean processPR;\n    private final boolean processCommit;\n    private final boolean enableMerge;\n    private final boolean jcheckMerge;\n    private final Set<String> mergeSources;\n    private final boolean enableBackport;\n    private final Map<String, List<PRRecord>> issuePRMap;\n    private final Map<String, Boolean> initializedPRs = new ConcurrentHashMap<>();\n    private final Map<String, String> jCheckConfMap = new HashMap<>();\n    private final Map<String, Set<String>> targetRefPRMap = new HashMap<>();\n    private final Approval approval;\n    private boolean initialRun = true;\n    private final boolean versionMismatchWarning;\n    private final boolean cleanCommandEnabled;\n    private final boolean checkContributorStatusForBackportCommand;\n    private final List<String> requiredCheckedLines;\n    private final List<TrailerCommand.TrailerConfig> trailerConfigs;\n\n    private Instant lastFullUpdate;\n\n    PullRequestBot(HostedRepository repo, HostedRepository censusRepo, String censusRef, LabelConfiguration labelConfiguration,\n                   Map<String, String> externalPullRequestCommands, Map<String, String> externalCommitCommands,\n                   Map<String, String> blockingCheckLabels, Set<String> readyLabels,\n                   Set<String> twoReviewersLabels, Set<String> twentyFourHoursLabels,\n                   Map<String, Pattern> readyComments, IssueProject issueProject,\n                   boolean useStaleReviews, boolean acceptSimpleMerges, Pattern allowedTargetBranches,\n                   Path seedStorage, HostedRepository confOverrideRepo, String confOverrideName,\n                   String confOverrideRef, String censusLink, Map<String, HostedRepository> forks,\n                   Set<String> integrators, Set<Integer> excludeCommitCommentsFrom, boolean enableCsr, boolean enableJep,\n                   boolean reviewCleanBackport, String mlbridgeBotName, MergePullRequestReviewConfiguration reviewMerge, boolean processPR, boolean processCommit,\n                   boolean enableMerge, Set<String> mergeSources, boolean jcheckMerge, boolean enableBackport,\n                   Map<String, List<PRRecord>> issuePRMap, Approval approval, boolean versionMismatchWarning, boolean cleanCommandEnabled,\n                   boolean checkContributorStatusForBackportCommand, List<String> requiredCheckedLines,\n                   List<TrailerCommand.TrailerConfig> trailerConfigs) {\n        remoteRepo = repo;\n        this.censusRepo = censusRepo;\n        this.censusRef = censusRef;\n        this.labelConfiguration = labelConfiguration;\n        this.externalPullRequestCommands = externalPullRequestCommands;\n        this.externalCommitCommands = externalCommitCommands;\n        this.blockingCheckLabels = blockingCheckLabels;\n        this.readyLabels = readyLabels;\n        this.twoReviewersLabels = twoReviewersLabels;\n        this.twentyFourHoursLabels = twentyFourHoursLabels;\n        this.issueProject = issueProject;\n        this.readyComments = readyComments;\n        this.useStaleReviews = useStaleReviews;\n        this.acceptSimpleMerges = acceptSimpleMerges;\n        this.allowedTargetBranches = allowedTargetBranches;\n        this.seedStorage = seedStorage;\n        this.confOverrideRepo = confOverrideRepo;\n        this.confOverrideName = confOverrideName;\n        this.confOverrideRef = confOverrideRef;\n        this.censusLink = censusLink;\n        this.forks = forks;\n        this.integrators = integrators;\n        this.excludeCommitCommentsFrom = excludeCommitCommentsFrom;\n        this.enableCsr = enableCsr;\n        this.enableJep = enableJep;\n        this.reviewCleanBackport = reviewCleanBackport;\n        this.mlbridgeBotName = mlbridgeBotName;\n        this.reviewMerge = reviewMerge;\n        this.processPR = processPR;\n        this.processCommit = processCommit;\n        this.enableMerge = enableMerge;\n        this.mergeSources = mergeSources;\n        this.jcheckMerge = jcheckMerge;\n        this.enableBackport = enableBackport;\n        this.issuePRMap = issuePRMap;\n        this.approval = approval;\n        this.versionMismatchWarning = versionMismatchWarning;\n        this.cleanCommandEnabled = cleanCommandEnabled;\n        this.checkContributorStatusForBackportCommand = checkContributorStatusForBackportCommand;\n        this.requiredCheckedLines = requiredCheckedLines;\n        this.trailerConfigs = trailerConfigs;\n\n        poller = new PullRequestPoller(repo, true);\n\n        // Only check recently updated when starting up to avoid congestion\n        lastFullUpdate = Instant.now();\n    }\n\n    static PullRequestBotBuilder newBuilder() {\n        return new PullRequestBotBuilder();\n    }\n\n    void scheduleRecheckAt(PullRequest pr, Instant expiresAt) {\n        log.info(\"Setting check metadata expiration to: \" + expiresAt + \" for PR #\" + pr.id());\n        poller.retryPullRequest(pr, expiresAt);\n    }\n\n    private List<WorkItem> getPullRequestWorkItems(List<PullRequest> pullRequests) {\n        var ret = new ArrayList<WorkItem>();\n\n        for (var pr : pullRequests) {\n            if (pr.state() == Issue.State.OPEN) {\n                if (initialRun) {\n                    ret.add(CheckWorkItem.fromInitialRunOfPRBot(this, pr.id(), e -> poller.retryPullRequest(pr), pr.updatedAt()));\n                } else {\n                    ret.add(CheckWorkItem.fromPRBot(this, pr.id(), e -> poller.retryPullRequest(pr), pr.updatedAt()));\n                }\n            } else {\n                // Closed PR's do not need to be checked\n                ret.add(new PullRequestCommandWorkItem(this, pr.id(), e -> poller.retryPullRequest(pr), pr.updatedAt(), true));\n            }\n        }\n\n        initialRun = false;\n\n        return ret;\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        var workItems = new ArrayList<WorkItem>();\n        if (processCommit) {\n            workItems.add(new CommitCommentsWorkItem(this, remoteRepo, excludeCommitCommentsFrom));\n        }\n        if (processPR) {\n            List<PullRequest> prs = poller.updatedPullRequests();\n            workItems.addAll(getPullRequestWorkItems(prs));\n\n            // Update targetRefPRMap\n            for (var pr : prs) {\n                var targetRef = pr.targetRef();\n                var prId = pr.id();\n                targetRefPRMap.values().forEach(s -> s.remove(prId));\n                if (pr.isOpen()) {\n                    targetRefPRMap.computeIfAbsent(targetRef, key -> new HashSet<>()).add(prId);\n                }\n            }\n\n            var activeBranches = remoteRepo.branches().stream()\n                    .map(HostedBranch::name)\n                    .toList();\n\n            var keysToRemove = targetRefPRMap.keySet().stream()\n                    .filter(key -> targetRefPRMap.get(key).isEmpty() || !activeBranches.contains(key))\n                    .toList();\n            keysToRemove.forEach(targetRefPRMap::remove);\n\n            var jCheckConfUpdateRelatedPRs = getJCheckConfUpdateRelatedPRs();\n            // Filter out duplicate prs\n            var filteredPrs = jCheckConfUpdateRelatedPRs.stream()\n                    .filter(pullRequest -> prs.stream()\n                            .noneMatch(pr -> pr.isSame(pullRequest)))\n                    .toList();\n            workItems.addAll(getPullRequestWorkItems(filteredPrs));\n            poller.lastBatchHandled();\n        }\n        return workItems;\n    }\n\n    private List<PullRequest> getJCheckConfUpdateRelatedPRs() {\n        var ret = new ArrayList<PullRequest>();\n        // If there is any pr targets on the ref, then the bot needs to check whether the .jcheck/conf updated in this ref\n        var allTargetRefs = targetRefPRMap.keySet().stream()\n                .filter(key -> !targetRefPRMap.get(key).isEmpty())\n                .toList();\n        for (var targetRef : allTargetRefs) {\n            try {\n                var currConfOpt = remoteRepo.fileContents(\".jcheck/conf\", targetRef);\n                if (currConfOpt.isEmpty()) {\n                    continue;\n                }\n                var currConf = currConfOpt.get();\n                if (!jCheckConfMap.containsKey(targetRef)) {\n                    jCheckConfMap.put(targetRef, currConf);\n                } else if (!jCheckConfMap.get(targetRef).equals(currConf)) {\n                    ret.addAll(remoteRepo.openPullRequestsWithTargetRef(targetRef));\n                    jCheckConfMap.put(targetRef, currConf);\n                }\n            } catch (UncheckedRestException e) {\n                // If the targetRef is invalid, fileContents() will throw a 404 instead of returning\n                // empty. In this case we should ignore this and continue processing other PRs.\n                // Any invalid refs will get removed from targetRefMap in the next round.\n                if (e.getStatusCode() != 404) {\n                    throw e;\n                }\n            }\n        }\n        return ret;\n    }\n\n    @Override\n    public List<WorkItem> processWebHook(JSONValue body) {\n        var webHook = remoteRepo.parseWebHook(body);\n        if (webHook.isEmpty()) {\n            return new ArrayList<>();\n        }\n        var workItems = new ArrayList<WorkItem>();\n        if (processCommit) {\n            workItems.add(new CommitCommentsWorkItem(this, remoteRepo, excludeCommitCommentsFrom));\n        }\n        if (processPR) {\n            workItems.addAll(getPullRequestWorkItems(webHook.get().updatedPullRequests()));\n        }\n        return workItems;\n    }\n\n    HostedRepository repo() {\n        return remoteRepo;\n    }\n\n    HostedRepository censusRepo() {\n        return censusRepo;\n    }\n\n    String censusRef() {\n        return censusRef;\n    }\n\n    LabelConfiguration labelConfiguration() {\n        return labelConfiguration;\n    }\n\n    Map<String, String> externalPullRequestCommands() {\n        return externalPullRequestCommands;\n    }\n\n    Map<String, String> externalCommitCommands() {\n        return externalCommitCommands;\n    }\n\n    Map<String, String> blockingCheckLabels() {\n        return blockingCheckLabels;\n    }\n\n    public Set<String> readyLabels() {\n        return readyLabels;\n    }\n\n    Set<String> twoReviewersLabels() {\n        return twoReviewersLabels;\n    }\n\n    Set<String> twentyFourHoursLabels() {\n        return twentyFourHoursLabels;\n    }\n\n    public Map<String, Pattern> readyComments() {\n        return readyComments;\n    }\n\n    IssueProject issueProject() {\n        return issueProject;\n    }\n\n    boolean useStaleReviews() {\n        return useStaleReviews;\n    }\n\n    boolean acceptSimpleMerges() {\n        return acceptSimpleMerges;\n    }\n\n    Pattern allowedTargetBranches() {\n        return allowedTargetBranches;\n    }\n\n    Optional<Path> seedStorage() {\n        return Optional.ofNullable(seedStorage);\n    }\n\n    Optional<HostedRepositoryPool> hostedRepositoryPool() {\n        return seedStorage().map(path -> new HostedRepositoryPool(path));\n    }\n\n    Optional<HostedRepository> confOverrideRepository() {\n        return Optional.ofNullable(confOverrideRepo);\n    }\n\n    String confOverrideName() {\n        return confOverrideName;\n    }\n\n    String confOverrideRef() {\n        return confOverrideRef;\n    }\n\n    Optional<URI> censusLink(Contributor contributor) {\n        if (censusLink == null) {\n            return Optional.empty();\n        }\n        return Optional.of(URI.create(censusLink.replace(\"{{contributor}}\", contributor.username())));\n    }\n\n    public boolean enableCsr() {\n        return enableCsr;\n    }\n\n    public boolean enableJep() {\n        return enableJep;\n    }\n\n    Optional<HostedRepository> writeableForkOf(HostedRepository upstream) {\n        return Optional.ofNullable(forks.get(upstream.name()));\n    }\n\n    public Map<String, HostedRepository> forks() {\n        return forks;\n    }\n\n    public boolean reviewCleanBackport() {\n        return reviewCleanBackport;\n    }\n\n    public List<String> requiredCheckedLines() {\n        return requiredCheckedLines;\n    }\n\n    public String mlbridgeBotName() {\n        return mlbridgeBotName;\n    }\n\n    public MergePullRequestReviewConfiguration reviewMerge() {\n        return reviewMerge;\n    }\n\n    public boolean enableMerge() {\n        return enableMerge;\n    }\n\n    public boolean jcheckMerge() {\n        return jcheckMerge;\n    }\n\n    public Set<String> mergeSources() {\n        return mergeSources;\n    }\n\n    public boolean enableBackport() {\n        return enableBackport;\n    }\n\n    public Set<String> integrators() {\n        return integrators;\n    }\n\n    public Map<String, List<PRRecord>> issuePRMap() {\n        return issuePRMap;\n    }\n\n    public Approval approval() {\n        return approval;\n    }\n\n    public boolean versionMismatchWarning() {\n        return versionMismatchWarning;\n    }\n\n    public boolean cleanCommandEnabled() {\n        return cleanCommandEnabled;\n    }\n\n    public boolean checkContributorStatusForBackportCommand() {\n        return checkContributorStatusForBackportCommand;\n    }\n\n    public List<TrailerCommand.TrailerConfig> trailerConfigs() {\n        return trailerConfigs;\n    }\n\n    public void addIssuePRMapping(String issueId, PRRecord prRecord) {\n        issuePRMap.putIfAbsent(issueId, new LinkedList<>());\n        List<PRRecord> prRecords = issuePRMap.get(issueId);\n        synchronized (prRecords) {\n            if (!prRecords.contains(prRecord)) {\n                prRecords.add(prRecord);\n            }\n        }\n    }\n\n    public void removeIssuePRMapping(String issueId, PRRecord prRecord) {\n        List<PRRecord> prRecords = issuePRMap.get(issueId);\n        if (prRecords != null) {\n            synchronized (prRecords) {\n                prRecords.remove(prRecord);\n            }\n        }\n    }\n\n    public Map<String, Boolean> initializedPRs() {\n        return initializedPRs;\n    }\n\n    @Override\n    public String name() {\n        return PullRequestBotFactory.NAME;\n    }\n\n    @Override\n    public String toString() {\n        return \"PullRequestBot@\" + remoteRepo.name();\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestBotBuilder.java",
    "content": "/*\n * Copyright (c) 2020, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.IssueProject;\nimport org.openjdk.skara.vcs.*;\n\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\npublic class PullRequestBotBuilder {\n    private HostedRepository repo;\n    private HostedRepository censusRepo;\n    private String censusRef = Branch.defaultFor(VCS.GIT).name();\n    private LabelConfiguration labelConfiguration = LabelConfigurationJson.builder().build();\n    private Map<String, String> externalPullRequestCommands = Map.of();\n    private Map<String, String> externalCommitCommands = Map.of();\n    private Map<String, String> blockingCheckLabels = Map.of();\n    private Set<String> readyLabels = Set.of();\n    private Set<String> twoReviewersLabels = Set.of();\n    private Set<String> twentyFourHoursLabels = Set.of();\n    private Map<String, Pattern> readyComments = Map.of();\n    private IssueProject issueProject = null;\n    private boolean useStaleReviews = true;\n    private boolean acceptSimpleMerges = false;\n    private Pattern allowedTargetBranches = Pattern.compile(\".*\");\n    private Path seedStorage = null;\n    private HostedRepository confOverrideRepo = null;\n    private String confOverrideName = \".conf/jcheck\";\n    private String confOverrideRef = Branch.defaultFor(VCS.GIT).name();\n    private String censusLink = null;\n    private boolean enableCsr = false;\n    private boolean enableJep = false;\n    private Map<String, HostedRepository> forks = Map.of();\n    private Set<String> integrators = Set.of();\n    private Set<Integer> excludeCommitCommentsFrom = Set.of();\n    private boolean reviewCleanBackport = false;\n    private String mlbridgeBotName;\n    private MergePullRequestReviewConfiguration reviewMerge = MergePullRequestReviewConfiguration.JCHECK;\n    private boolean processPR = true;\n    private boolean processCommit = true;\n    private boolean enableMerge = true;\n    private boolean jcheckMerge = false;\n    private Set<String> mergeSources = Set.of();\n    private boolean enableBackport = true;\n    private Map<String, List<PRRecord>> issuePRMap;\n    private Approval approval = null;\n    private boolean versionMismatchWarning = false;\n    private boolean cleanCommandEnabled = true;\n    private boolean checkContributorStatusForBackportCommand = true;\n    private List<String> requiredCheckedLines = new ArrayList<String>();\n    private List<TrailerCommand.TrailerConfig> trailerConfigs = List.of();\n\n    PullRequestBotBuilder() {\n    }\n\n    public PullRequestBotBuilder repo(HostedRepository repo) {\n        this.repo = repo;\n        return this;\n    }\n\n    public PullRequestBotBuilder censusRepo(HostedRepository censusRepo) {\n        this.censusRepo = censusRepo;\n        return this;\n    }\n\n    public PullRequestBotBuilder censusRef(String censusRef) {\n        this.censusRef = censusRef;\n        return this;\n    }\n\n    public PullRequestBotBuilder labelConfiguration(LabelConfiguration labelConfiguration) {\n        this.labelConfiguration = labelConfiguration;\n        return this;\n    }\n\n    public PullRequestBotBuilder externalPullRequestCommands(Map<String, String> externalPullRequestCommands) {\n        this.externalPullRequestCommands = externalPullRequestCommands;\n        return this;\n    }\n\n    public PullRequestBotBuilder externalCommitCommands(Map<String, String> externalCommitCommands) {\n        this.externalCommitCommands = externalCommitCommands;\n        return this;\n    }\n\n    public PullRequestBotBuilder blockingCheckLabels(Map<String, String> blockingCheckLabels) {\n        this.blockingCheckLabels = blockingCheckLabels;\n        return this;\n    }\n\n    public PullRequestBotBuilder readyLabels(Set<String> readyLabels) {\n        this.readyLabels = readyLabels;\n        return this;\n    }\n\n    public PullRequestBotBuilder twoReviewersLabels(Set<String> twoReviewersLabels) {\n        this.twoReviewersLabels = twoReviewersLabels;\n        return this;\n    }\n\n    public PullRequestBotBuilder twentyFourHoursLabels(Set<String> twentyFourHoursLabels) {\n        this.twentyFourHoursLabels = twentyFourHoursLabels;\n        return this;\n    }\n\n    public PullRequestBotBuilder readyComments(Map<String, Pattern> readyComments) {\n        this.readyComments = readyComments;\n        return this;\n    }\n\n    public PullRequestBotBuilder issueProject(IssueProject issueProject) {\n        this.issueProject = issueProject;\n        return this;\n    }\n\n    public PullRequestBotBuilder useStaleReviews(boolean useStaleReviews) {\n        this.useStaleReviews = useStaleReviews;\n        return this;\n    }\n\n    public PullRequestBotBuilder acceptSimpleMerges(boolean acceptSimpleMerges) {\n        this.acceptSimpleMerges = acceptSimpleMerges;\n        return this;\n    }\n\n    public PullRequestBotBuilder allowedTargetBranches(String allowedTargetBranches) {\n        this.allowedTargetBranches = Pattern.compile(allowedTargetBranches);\n        return this;\n    }\n\n    public PullRequestBotBuilder seedStorage(Path seedStorage) {\n        this.seedStorage = seedStorage;\n        return this;\n    }\n\n    public PullRequestBotBuilder confOverrideRepo(HostedRepository confOverrideRepo) {\n        this.confOverrideRepo = confOverrideRepo;\n        return this;\n    }\n\n    public PullRequestBotBuilder confOverrideName(String confOverrideName) {\n        this.confOverrideName = confOverrideName;\n        return this;\n    }\n\n    public PullRequestBotBuilder confOverrideRef(String confOverrideRef) {\n        this.confOverrideRef = confOverrideRef;\n        return this;\n    }\n\n    public PullRequestBotBuilder censusLink(String censusLink) {\n        this.censusLink = censusLink;\n        return this;\n    }\n\n    public PullRequestBotBuilder enableCsr(boolean enableCsr) {\n        this.enableCsr = enableCsr;\n        return this;\n    }\n\n    public PullRequestBotBuilder enableJep(boolean enableJep) {\n        this.enableJep = enableJep;\n        return this;\n    }\n\n    public PullRequestBotBuilder forks(Map<String, HostedRepository> forks) {\n        this.forks = forks;\n        return this;\n    }\n\n    public PullRequestBotBuilder integrators(Set<String> integrators) {\n        this.integrators = new HashSet<>(integrators);\n        return this;\n    }\n\n    public PullRequestBotBuilder excludeCommitCommentsFrom(Set<Integer> excludeCommitCommentsFrom) {\n        this.excludeCommitCommentsFrom = excludeCommitCommentsFrom;\n        return this;\n    }\n\n    public PullRequestBotBuilder reviewCleanBackport(boolean reviewCleanBackport) {\n        this.reviewCleanBackport = reviewCleanBackport;\n        return this;\n    }\n\n    public PullRequestBotBuilder mlbridgeBotName(String mlbridgeBotName) {\n        this.mlbridgeBotName = mlbridgeBotName;\n        return this;\n    }\n\n    public PullRequestBotBuilder reviewMerge(MergePullRequestReviewConfiguration reviewMerge) {\n        this.reviewMerge = reviewMerge;\n        return this;\n    }\n\n    public PullRequestBotBuilder processPR(boolean processPR) {\n        this.processPR = processPR;\n        return this;\n    }\n\n    public PullRequestBotBuilder processCommit(boolean processCommit) {\n        this.processCommit = processCommit;\n        return this;\n    }\n\n    public PullRequestBotBuilder enableMerge(boolean enableMerge) {\n        this.enableMerge = enableMerge;\n        return this;\n    }\n\n    public PullRequestBotBuilder mergeSources(Set<String> mergeSources) {\n        this.mergeSources = mergeSources;\n        return this;\n    }\n\n    public PullRequestBotBuilder jcheckMerge(boolean jcheckMerge) {\n        this.jcheckMerge = jcheckMerge;\n        return this;\n    }\n\n    public PullRequestBotBuilder enableBackport(boolean enableBackport) {\n        this.enableBackport = enableBackport;\n        return this;\n    }\n\n    public PullRequestBotBuilder issuePRMap(Map<String, List<PRRecord>> issuePRMap) {\n        this.issuePRMap = issuePRMap;\n        return this;\n    }\n\n    public PullRequestBotBuilder approval(Approval approval) {\n        this.approval = approval;\n        return this;\n    }\n\n    public PullRequestBotBuilder versionMismatchWarning(boolean versionMismatchWarning) {\n        this.versionMismatchWarning = versionMismatchWarning;\n        return this;\n    }\n\n    public PullRequestBotBuilder cleanCommandEnabled(boolean cleanCommandEnabled) {\n        this.cleanCommandEnabled = cleanCommandEnabled;\n        return this;\n    }\n\n    public PullRequestBotBuilder checkContributorStatusForBackportCommand(boolean checkContributorStatusForBackportCommand) {\n        this.checkContributorStatusForBackportCommand = checkContributorStatusForBackportCommand;\n        return this;\n    }\n\n    public PullRequestBotBuilder requiredCheckedLines(List<String> requiredCheckedLines) {\n        this.requiredCheckedLines = requiredCheckedLines;\n        return this;\n    }\n\n    public PullRequestBotBuilder trailerConfigs(List<TrailerCommand.TrailerConfig> trailerConfigs) {\n        this.trailerConfigs = trailerConfigs;\n        return this;\n    }\n\n    public PullRequestBot build() {\n        return new PullRequestBot(repo, censusRepo, censusRef, labelConfiguration, externalPullRequestCommands,\n                externalCommitCommands, blockingCheckLabels, readyLabels, twoReviewersLabels, twentyFourHoursLabels,\n                readyComments, issueProject, useStaleReviews, acceptSimpleMerges, allowedTargetBranches, seedStorage, confOverrideRepo,\n                confOverrideName, confOverrideRef, censusLink, forks, integrators, excludeCommitCommentsFrom, enableCsr,\n                enableJep, reviewCleanBackport, mlbridgeBotName, reviewMerge, processPR, processCommit, enableMerge,\n                mergeSources, jcheckMerge, enableBackport, issuePRMap, approval, versionMismatchWarning, cleanCommandEnabled,\n                checkContributorStatusForBackportCommand, requiredCheckedLines, trailerConfigs);\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestBotFactory.java",
    "content": "/*\n * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.IssueProject;\nimport org.openjdk.skara.json.*;\n\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class PullRequestBotFactory implements BotFactory {\n    static final String NAME = \"pr\";\n    @Override\n    public String name() {\n        return NAME;\n    }\n\n    @Override\n    public List<Bot> create(BotConfiguration configuration) {\n        var ret = new ArrayList<Bot>();\n        var specific = configuration.specific();\n        var repositories = new HashMap<IssueProject, List<HostedRepository>>();\n        var repositoriesForCSR = new HashMap<IssueProject, List<HostedRepository>>();\n        var pullRequestBotMap = new HashMap<String, PullRequestBot>();\n        var issueProjectToIssuePRMapMap = new HashMap<IssueProject, Map<String, List<PRRecord>>>();\n\n        var externalPullRequestCommands = new HashMap<String, String>();\n        if (specific.contains(\"external\") && specific.get(\"external\").contains(\"pr\")) {\n            for (var command : specific.get(\"external\").get(\"pr\").fields()) {\n                externalPullRequestCommands.put(command.name(), command.value().asString());\n            }\n        }\n\n        var externalCommitCommands = new HashMap<String, String>();\n        if (specific.contains(\"external\") && specific.get(\"external\").contains(\"commit\")) {\n            for (var command : specific.get(\"external\").get(\"commit\").fields()) {\n                externalCommitCommands.put(command.name(), command.value().asString());\n            }\n        }\n\n        var blockers = new HashMap<String, String>();\n        if (specific.contains(\"blockers\")) {\n            for (var blocker : specific.get(\"blockers\").fields()) {\n                blockers.put(blocker.name(), blocker.value().asString());\n            }\n        }\n\n        var forks = new HashMap<String, HostedRepository>();\n        if (specific.contains(\"forks\")) {\n            for (var fork : specific.get(\"forks\").fields()) {\n                var repo = configuration.repository(fork.value().asString());\n                var upstream = configuration.repository(fork.name());\n                forks.put(upstream.name(), repo);\n            }\n        }\n\n        var mlbridgeBotName = \"\";\n        if (specific.contains(\"mlbridge\")) {\n            mlbridgeBotName = specific.get(\"mlbridge\").asString();\n        }\n\n        var excludeCommitCommentsFrom = new HashSet<Integer>();\n        if (specific.contains(\"exclude-commit-comments-from\")) {\n            specific.get(\"exclude-commit-comments-from\")\n                    .stream()\n                    .map(o -> o.asInt())\n                    .forEach(id -> excludeCommitCommentsFrom.add(id));\n        }\n\n        var readyLabels = specific.get(\"ready\").get(\"labels\").stream()\n                                  .map(JSONValue::asString)\n                                  .collect(Collectors.toSet());\n        var readyComments = specific.get(\"ready\").get(\"comments\").stream()\n                                    .map(JSONValue::asObject)\n                                    .collect(Collectors.toMap(obj -> obj.get(\"user\").asString(),\n                                                              obj -> Pattern.compile(obj.get(\"pattern\").asString())));\n\n        var labelConfigurations = new HashMap<String, LabelConfiguration>();\n        for (var labelGroup : specific.get(\"labels\").fields()) {\n            if (labelGroup.value().contains(\"repository\")) {\n                var repository = configuration.repository(labelGroup.value().get(\"repository\").asString());\n                var ref = configuration.repositoryRef(labelGroup.value().get(\"repository\").asString());\n                var filename = labelGroup.value().get(\"filename\").asString();\n                labelConfigurations.put(labelGroup.name(),\n                                        LabelConfigurationHostedRepository.from(repository, ref, filename));\n            } else {\n                labelConfigurations.put(labelGroup.name(),\n                                        LabelConfigurationJson.from(labelGroup.value()));\n            }\n        }\n\n        List<String> requiredCheckedLines = new ArrayList<String>();\n        if (specific.contains(\"requiredCheckedLines\")) {\n            requiredCheckedLines =\n                specific.get(\"requiredCheckedLines\").asArray().stream().map(JSONValue::asString).toList();\n        }\n\n        var globalTrailers = parseTrailers(specific.get(\"trailers\"));\n\n        for (var repo : specific.get(\"repositories\").fields()) {\n            var censusRepo = configuration.repository(repo.value().get(\"census\").asString());\n            var censusRef = configuration.repositoryRef(repo.value().get(\"census\").asString());\n            var repository = configuration.repository(repo.name());\n            var botBuilder = PullRequestBot.newBuilder()\n                                           .repo(repository)\n                                           .censusRepo(censusRepo)\n                                           .censusRef(censusRef)\n                                           .blockingCheckLabels(blockers)\n                                           .readyLabels(readyLabels)\n                                           .readyComments(readyComments)\n                                           .externalPullRequestCommands(externalPullRequestCommands)\n                                           .externalCommitCommands(externalCommitCommands)\n                                           .seedStorage(configuration.storageFolder().resolve(\"seeds\"))\n                                           .excludeCommitCommentsFrom(excludeCommitCommentsFrom)\n                                           .forks(forks)\n                                           .mlbridgeBotName(mlbridgeBotName)\n                                           .requiredCheckedLines(requiredCheckedLines);\n\n            if (repo.value().contains(\"labels\")) {\n                var labelGroup = repo.value().get(\"labels\").asString();\n                if (!labelConfigurations.containsKey(labelGroup)) {\n                    throw new RuntimeException(\"Unknown label group: \" + labelGroup);\n                }\n                botBuilder.labelConfiguration(labelConfigurations.get(labelGroup));\n            }\n            if (repo.value().contains(\"two-reviewers\")) {\n                var labels = repo.value().get(\"two-reviewers\")\n                                         .stream()\n                                         .map(label -> label.asString())\n                                         .collect(Collectors.toSet());\n                botBuilder.twoReviewersLabels(labels);\n            }\n            if (repo.value().contains(\"24h\")) {\n                var labels = repo.value().get(\"24h\")\n                                         .stream()\n                                         .map(label -> label.asString())\n                                         .collect(Collectors.toSet());\n                botBuilder.twentyFourHoursLabels(labels);\n            }\n            IssueProject issueProject = null;\n            if (repo.value().contains(\"issues\")) {\n                issueProject = configuration.issueProject(repo.value().get(\"issues\").asString());\n                botBuilder.issueProject(issueProject);\n                repositories.putIfAbsent(issueProject, new ArrayList<>());\n                repositories.get(issueProject).add(repository);\n                issueProjectToIssuePRMapMap.putIfAbsent(issueProject, new ConcurrentHashMap<>());\n                botBuilder.issuePRMap(issueProjectToIssuePRMapMap.get(issueProject));\n            }\n            if (repo.value().contains(\"useStaleReviews\")) {\n                botBuilder.useStaleReviews(repo.value().get(\"useStaleReviews\").asBoolean());\n            }\n            if (repo.value().contains(\"acceptSimpleMerges\")) {\n                botBuilder.acceptSimpleMerges(repo.value().get(\"acceptSimpleMerges\").asBoolean());\n            }\n            if (repo.value().contains(\"targetbranches\")) {\n                botBuilder.allowedTargetBranches(repo.value().get(\"targetbranches\").asString());\n            }\n            if (repo.value().contains(\"jcheck\")) {\n                botBuilder.confOverrideRepo(configuration.repository(repo.value().get(\"jcheck\").get(\"repo\").asString()));\n                botBuilder.confOverrideRef(configuration.repositoryRef(repo.value().get(\"jcheck\").get(\"repo\").asString()));\n                if (repo.value().get(\"jcheck\").contains(\"name\")) {\n                    botBuilder.confOverrideName(repo.value().get(\"jcheck\").get(\"name\").asString());\n                }\n            }\n            if (repo.value().contains(\"censuslink\")) {\n                botBuilder.censusLink(repo.value().get(\"censuslink\").asString());\n            }\n            if (repo.value().contains(\"csr\")) {\n                var enableCsr = repo.value().get(\"csr\").asBoolean();\n                botBuilder.enableCsr(enableCsr);\n                if (enableCsr && issueProject != null) {\n                    repositoriesForCSR.putIfAbsent(issueProject, new ArrayList<>());\n                    repositoriesForCSR.get(issueProject).add(repository);\n                }\n            }\n            if (repo.value().contains(\"jep\")) {\n                botBuilder.enableJep(repo.value().get(\"jep\").asBoolean());\n            }\n            if (repo.value().contains(\"merge\")) {\n                botBuilder.enableMerge(repo.value().get(\"merge\").asBoolean());\n            }\n            if (repo.value().contains(\"backport\")) {\n                botBuilder.enableBackport(repo.value().get(\"backport\").asBoolean());\n            }\n            if (repo.value().contains(\"integrators\")) {\n                var integrators = repo.value().get(\"integrators\")\n                        .stream()\n                        .map(JSONValue::asString)\n                        .collect(Collectors.toSet());\n                botBuilder.integrators(integrators);\n            }\n            if (repo.value().contains(\"reviewCleanBackport\")) {\n                botBuilder.reviewCleanBackport(repo.value().get(\"reviewCleanBackport\").asBoolean());\n            }\n            if (repo.value().contains(\"reviewMerge\")) {\n                MergePullRequestReviewConfiguration result = null;\n\n                var val = repo.value().get(\"reviewMerge\").asString().toLowerCase().trim();\n                if (val.equals(\"always\")) {\n                    result = MergePullRequestReviewConfiguration.ALWAYS;\n                } else if (val.equals(\"never\")) {\n                    result = MergePullRequestReviewConfiguration.NEVER;\n                } else if (val.equals(\"jcheck\")) {\n                    result = MergePullRequestReviewConfiguration.JCHECK;\n                } else {\n                    throw new RuntimeException(\"Unexpected value for key \\\"reviewMerge\\\": '\" +\n                                               repo.value().get(\"reviewMerge\") + \"', \" +\n                                               \"expected one of \\\"always\\\", \\\"never\\\" or \\\"jcheck\\\"\");\n                }\n\n                botBuilder.reviewMerge(result);\n            }\n            if (repo.value().contains(\"processPR\")) {\n                botBuilder.processPR(repo.value().get(\"processPR\").asBoolean());\n            }\n            if (repo.value().contains(\"processCommit\")) {\n                botBuilder.processCommit(repo.value().get(\"processCommit\").asBoolean());\n            }\n            if (repo.value().contains(\"jcheckMerge\")) {\n                botBuilder.jcheckMerge(repo.value().get(\"jcheckMerge\").asBoolean());\n            }\n\n            if (repo.value().contains(\"mergeSources\")) {\n                var mergeSources = repo.value().get(\"mergeSources\").stream()\n                        .map(JSONValue::asString)\n                        .collect(Collectors.toSet());\n                botBuilder.mergeSources(mergeSources);\n            }\n\n            if (repo.value().contains(\"approval\")) {\n                var approvalJSON = repo.value().get(\"approval\");\n                String prefix = approvalJSON.contains(\"prefix\") ? approvalJSON.get(\"prefix\").asString() : \"\";\n                String request = approvalJSON.get(\"request\").asString();\n                String approved = approvalJSON.get(\"approved\").asString();\n                String rejected = approvalJSON.get(\"rejected\").asString();\n                String documentLink = approvalJSON.get(\"documentLink\").asString();\n                // default value is true\n                boolean approvalComment = !approvalJSON.contains(\"approvalComment\") || approvalJSON.get(\"approvalComment\").asBoolean();\n                //default value is maintainer approval\n                String approvalTerm = approvalJSON.contains(\"approvalTerm\") ? approvalJSON.get(\"approvalTerm\").asString() : \"maintainer approval\";\n                Approval approval = new Approval(prefix, request, approved, rejected, documentLink, approvalComment, approvalTerm);\n                if (approvalJSON.contains(\"branches\")) {\n                    for (var branch : approvalJSON.get(\"branches\").fields()) {\n                        approval.addBranchPrefix(Pattern.compile(branch.name()), branch.value().get(\"prefix\").asString());\n                    }\n                }\n                botBuilder.approval(approval);\n            }\n\n            if (repo.value().contains(\"versionMismatchWarning\")) {\n                botBuilder.versionMismatchWarning(repo.value().get(\"versionMismatchWarning\").asBoolean());\n            }\n\n            if (repo.value().contains(\"cleanCommandEnabled\")) {\n                botBuilder.cleanCommandEnabled(repo.value().get(\"cleanCommandEnabled\").asBoolean());\n            }\n\n            if (repo.value().contains(\"checkContributorStatusForBackportCommand\")) {\n                botBuilder.checkContributorStatusForBackportCommand(repo.value().get(\"checkContributorStatusForBackportCommand\").asBoolean());\n            }\n\n            // A repository can override the \"default\" required checked lines that are\n            // configured for all repositories handled by the bot.\n            if (repo.value().contains(\"requiredCheckedLines\")) {\n                var requiredCheckedLinesOverride = repo.value()\n                                                       .get(\"requiredCheckedLines\")\n                                                       .asArray()\n                                                       .stream()\n                                                       .map(JSONValue::asString)\n                                                       .toList();\n                botBuilder.requiredCheckedLines(requiredCheckedLinesOverride);\n            }\n\n            var trailers = parseTrailers(repo.value().get(\"trailers\"));\n            if (trailers.isEmpty()) {\n                trailers = globalTrailers;\n            }\n            botBuilder.trailerConfigs(trailers);\n\n            var prBot = botBuilder.build();\n            pullRequestBotMap.put(repository.name(), prBot);\n            ret.add(prBot);\n        }\n\n        // Create a CSRIssueBot for each issueProject which associated with at least one csr enabled repository\n        for (var issueProject : repositoriesForCSR.keySet()) {\n            ret.add(0, new CSRIssueBot(issueProject, repositoriesForCSR.get(issueProject), pullRequestBotMap,\n                    issueProjectToIssuePRMapMap.get(issueProject)));\n        }\n\n        // Create an IssueBot for each issueProject\n        for (var issueProject : issueProjectToIssuePRMapMap.keySet()) {\n            ret.add(0, new IssueBot(issueProject, repositories.get(issueProject), pullRequestBotMap,\n                    issueProjectToIssuePRMapMap.get(issueProject)));\n        }\n\n        return ret;\n    }\n\n    private List<TrailerCommand.TrailerConfig> parseTrailers(JSONValue trailerArray) {\n        if (trailerArray != null) {\n            return trailerArray.asArray().stream()\n                    .map(js -> {\n                        var values = js.get(\"values\");\n                        List<Pattern> patternList;\n                        if (values.isArray()) {\n                            patternList = values.stream()\n                                    .map(v -> Pattern.compile(v.asString()))\n                                    .toList();\n                        } else {\n                            patternList = List.of(Pattern.compile(values.asString()));\n                        }\n                        // default type is \"single\"\n                        var type = TrailerCommand.TrailerType.fromString(js.contains(\"type\") ? js.get(\"type\").asString() : \"single\");\n                        JSONValue jsonAlias = js.get(\"alias\");\n                        String alias;\n                        if (jsonAlias == null) {\n                            alias = null;\n                        } else {\n                            alias = jsonAlias.asString();\n                        }\n                        return new TrailerCommand.TrailerConfig(js.get(\"key\").asString(),\n                                alias,\n                                js.get(\"description\").asString(),\n                                type,\n                                patternList);\n                    })\n                    .toList();\n        } else {\n            return List.of();\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestCheckIssueVisitor.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.jcheck.*;\nimport org.openjdk.skara.jcheck.Check;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\nclass PullRequestCheckIssueVisitor implements IssueVisitor {\n    private final List<CheckAnnotation> annotations = new LinkedList<>();\n    private final Set<Check> enabledChecks;\n    private final Map<Class<? extends Check>, List<String>> errorFailedChecks = new HashMap<>();\n    private final Map<Class<? extends Check>, List<String>> warningFailedChecks = new HashMap<>();\n    private boolean readyForReview;\n\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n\n    private final Set<Class<? extends Check>> displayedChecks = Set.of(\n            DuplicateIssuesCheck.class,\n            ReviewersCheck.class,\n            WhitespaceCheck.class,\n            IssuesCheck.class\n    );\n\n    private JCheckConfiguration configuration;\n\n    PullRequestCheckIssueVisitor(Set<Check> enabledChecks) {\n        this.enabledChecks = enabledChecks;\n        readyForReview = true;\n    }\n\n    private void setNotReadyForReviewOnError(Severity severity) {\n        if (severity == Severity.ERROR) {\n            readyForReview = false;\n        }\n    }\n\n    private void addMessage(Check check, String message, Severity severity) {\n        if (severity == Severity.ERROR) {\n            errorFailedChecks.computeIfAbsent(check.getClass(), k -> new ArrayList<>()).add(message);\n        } else if (severity == Severity.WARNING) {\n            warningFailedChecks.computeIfAbsent(check.getClass(), k -> new ArrayList<>()).add(message);\n        }\n    }\n\n    List<String> errorFailedChecksMessages() {\n        return errorFailedChecks.values().stream().flatMap(List::stream).toList();\n    }\n\n\n    boolean hasErrors(boolean reviewNeeded) {\n        if (reviewNeeded) {\n            return !errorFailedChecks.isEmpty();\n        } else {\n            return errorFailedChecks.keySet().stream()\n                    .anyMatch(check -> !check.equals(ReviewersCheck.class));\n        }\n    }\n\n    List<String> warningFailedChecksMessages() {\n        return warningFailedChecks.values().stream().flatMap(List::stream).toList();\n    }\n\n    List<String> hiddenWarningMessages() {\n        return warningFailedChecks.entrySet().stream()\n                .filter(entry -> !displayedChecks.contains(entry.getKey()))\n                .map(Map.Entry::getValue)\n                .flatMap(List::stream)\n                .sorted()\n                .collect(Collectors.toList());\n    }\n\n    List<String> hiddenErrorMessages() {\n        return errorFailedChecks.entrySet().stream()\n                .filter(entry -> !displayedChecks.contains(entry.getKey()))\n                .map(Map.Entry::getValue)\n                .flatMap(List::stream)\n                .sorted()\n                .collect(Collectors.toList());\n    }\n\n    /**\n     * Get all the displayed checks with results.\n     */\n    Map<String, Boolean> getChecks() {\n        return enabledChecks.stream()\n                            .filter(check -> displayedChecks.contains(check.getClass()))\n                            .collect(Collectors.toMap(this::checkDescription,\n                                                      check -> !errorFailedChecks.containsKey(check.getClass())));\n    }\n\n    /**\n     * Get all the displayed checks with results that were used to decide if this change is ready for\n     * review.\n     */\n    Map<String, Boolean> getReadyForReviewChecks() {\n        return enabledChecks.stream()\n                            .filter(check -> displayedChecks.contains(check.getClass()))\n                            .filter(check -> !(check instanceof ReviewersCheck))\n                            .collect(Collectors.toMap(this::checkDescription,\n                                                      check -> !errorFailedChecks.containsKey(check.getClass())));\n    }\n\n    private String checkDescription(Check check) {\n        if (check instanceof ReviewersCheck && configuration != null) {\n            return check.description() + \" (\" + configuration.checks().reviewers().getReviewRequirements() + \")\";\n        }\n        return check.description();\n    }\n\n    List<CheckAnnotation> getAnnotations() { return annotations; }\n\n    boolean isReadyForReview() {\n        return readyForReview;\n    }\n\n    void setConfiguration(JCheckConfiguration configuration) {\n        this.configuration = configuration;\n    }\n\n    public void visit(DuplicateIssuesIssue issue) {\n        var id = issue.issue().id();\n        var other = issue.hashes()\n                .stream()\n                .map(Hash::abbreviate)\n                .map(s -> \"         - \" + s)\n                .toList();\n\n        var output = new StringBuilder();\n        output.append(\"Issue id \").append(id).append(\" is already used in these commits:\\n\");\n        other.forEach(h -> output.append(\" * \").append(h).append(\"\\n\"));\n        addMessage(issue.check(), output.toString(), issue.severity());\n        setNotReadyForReviewOnError(issue.severity());\n    }\n\n    @Override\n    public void visit(TagIssue issue) {\n        log.fine(\"ignored: illegal tag name: \" + issue.tag().name());\n    }\n\n    @Override\n    public void visit(BranchIssue issue) {\n        log.fine(\"ignored: illegal branch name: \" + issue.branch().name());\n    }\n\n    @Override\n    public void visit(SelfReviewIssue issue) {\n        var message = issue.severity().equals(Severity.ERROR) ? \"Self-reviews are not allowed\" :\n                \"Self-reviews are not recommended\";\n        addMessage(issue.check(), message, issue.severity());\n        setNotReadyForReviewOnError(issue.severity());\n    }\n\n    @Override\n    public void visit(TooFewReviewersIssue issue) {\n        addMessage(issue.check(), String.format(\"Too few reviewers with at least role %s found (have %d, need at least %d)\",\n                issue.role(), issue.numActual(), issue.numRequired()), issue.severity());\n    }\n\n    @Override\n    public void visit(InvalidReviewersIssue issue) {\n        var invalid = String.join(\", \", issue.invalid());\n        addMessage(issue.check(), \"Invalid reviewers \" + invalid, issue.severity());\n    }\n\n    @Override\n    public void visit(MergeMessageIssue issue) {\n        var message = String.join(\"\\n\", issue.commit().message());\n        var desc = \"Merge commit message is not `\" + issue.expected() + \"`, but:\";\n        if (issue.commit().message().size() == 1) {\n            desc += \" `\" + message + \"`\";\n        } else {\n            desc += \"\\n\" +\n                    \"```\\n\" +\n                    message +\n                    \"```\";\n        }\n        addMessage(issue.check(), desc, issue.severity());\n    }\n\n    @Override\n    public void visit(HgTagCommitIssue e) {\n        log.fine(\"ignored: invalid tag commit\");\n    }\n\n    @Override\n    public void visit(CommitterIssue e) {\n        log.fine(\"ignored: invalid author: \" + e.commit().author().name());\n    }\n\n    @Override\n    public void visit(CommitterNameIssue issue) {\n        log.fine(\"ignored: invalid committer name\");\n    }\n\n    @Override\n    public void visit(CommitterEmailIssue issue) {\n        log.fine(\"ignored: invalid committer email\");\n    }\n\n    @Override\n    public void visit(AuthorNameIssue issue) {\n        // We only get here for contributors without an OpenJDK username\n        var message = issue.severity().equals(Severity.ERROR) ? \"Pull request's HEAD commit must contain a full name\" :\n                \"Pull request's HEAD commit doesn't contain a full name\";\n        addMessage(issue.check(), message, issue.severity());\n        setNotReadyForReviewOnError(issue.severity());\n    }\n\n    @Override\n    public void visit(AuthorEmailIssue issue) {\n        // We only get here for contributors without an OpenJDK username\n        var message = issue.severity().equals(Severity.ERROR) ? \"Pull request's HEAD commit must contain a valid e-mail\" :\n                \"Pull request's HEAD commit doesn't contain a valid e-mail\";\n        addMessage(issue.check(), message, issue.severity());\n        setNotReadyForReviewOnError(issue.severity());\n    }\n\n    @Override\n    public void visit(WhitespaceIssue issue) {\n        var startColumn = Integer.MAX_VALUE;\n        var endColumn = Integer.MIN_VALUE;\n        var details = new LinkedList<String>();\n        for (var error : issue.errors()) {\n            startColumn = Math.min(error.index(), startColumn);\n            endColumn = Math.max(error.index(), endColumn);\n            details.add(\"Column \" + error.index() + \": \" + error.kind().toString());\n        }\n\n        var annotationBuilder = CheckAnnotationBuilder.create(\n                issue.path().toString(),\n                issue.row(),\n                issue.row(),\n                CheckAnnotationLevel.FAILURE,\n                String.join(\"  \\n\", details));\n\n        if (startColumn < Integer.MAX_VALUE) {\n            annotationBuilder.startColumn(startColumn);\n        }\n        if (endColumn > Integer.MIN_VALUE) {\n            annotationBuilder.endColumn(endColumn);\n        }\n\n        var annotation = annotationBuilder.title(\"Whitespace \" + issue.severity().toString()).build();\n        annotations.add(annotation);\n\n        addMessage(issue.check(), \"Whitespace \" + issue.severity().toString() + \"s\", issue.severity());\n        setNotReadyForReviewOnError(issue.severity());\n    }\n\n    @Override\n    public void visit(MessageIssue issue) {\n        var message = String.join(\"\\n\", issue.commit().message());\n        log.warning(\"Incorrectly formatted commit message: \" + message);\n        addMessage(issue.check(), \"Incorrectly formatted commit message\", issue.severity());\n    }\n\n    @Override\n    public void visit(MessageWhitespaceIssue issue) {\n        String desc;\n        if (issue.kind() == MessageWhitespaceIssue.Whitespace.TRAILING) {\n            desc = \"trailing whitespace\";\n        } else if (issue.kind() == MessageWhitespaceIssue.Whitespace.CR) {\n            desc = \"a carriage return\";\n        } else if (issue.kind() == MessageWhitespaceIssue.Whitespace.TAB) {\n            desc = \"a tab\";\n        } else {\n            desc = \"an unknown kind of whitespace (\" + issue.kind().name() + \")\";\n        }\n        addMessage(issue.check(), \"The commit message contains \" + desc + \" on line \" + issue.line(), issue.severity());\n        setNotReadyForReviewOnError(issue.severity());\n    }\n\n    @Override\n    public void visit(IssuesIssue issue) {\n        addMessage(issue.check(), \"The commit message does not reference any issue. To add an issue reference to this PR, \" +\n                \"edit the title to be of the format `issue number`: `message`.\", issue.severity());\n        setNotReadyForReviewOnError(issue.severity());\n    }\n\n    @Override\n    public void visit(ExecutableIssue issue) {\n        var message = issue.severity().equals(Severity.ERROR) ? String.format(\"Executable files are not allowed (file: %s)\", issue.path())\n                : String.format(\"Patch contains an executable file (%s)\", issue.path());\n        addMessage(issue.check(), message, issue.severity());\n        setNotReadyForReviewOnError(issue.severity());\n    }\n\n    @Override\n    public void visit(SymlinkIssue issue) {\n        var message = issue.severity().equals(Severity.ERROR) ? String.format(\"Symbolic links are not allowed (file: %s)\", issue.path())\n                : String.format(\"Patch contains a symbolic link (%s)\", issue.path());\n        addMessage(issue.check(), message, issue.severity());\n        setNotReadyForReviewOnError(issue.severity());\n    }\n\n    @Override\n    public void visit(BinaryIssue issue) {\n        var message = issue.severity().equals(Severity.ERROR) ? String.format(\"Binary files are not allowed (file: %s)\", issue.path())\n                : String.format(\"Patch contains a binary file (%s)\", issue.path());\n        addMessage(issue.check(), message, issue.severity());\n        setNotReadyForReviewOnError(issue.severity());\n    }\n\n    @Override\n    public void visit(ProblemListsIssue issue) {\n        addMessage(issue.check(), issue.issue() + \" is used in problem lists: \" + issue.files(), issue.severity());\n    }\n\n    @Override\n    public void visit(IssuesTitleIssue issue) {\n        List<String> messages = new ArrayList<>();\n        if (!issue.issuesWithTrailingPeriod().isEmpty()) {\n            messages.add(\"Found trailing period in issue title for \" + String.join(\", \", issue.issuesWithTrailingPeriod()));\n        }\n        if (!issue.issuesWithLeadingLowerCaseLetter().isEmpty()) {\n            messages.add(\"Found leading lowercase letter in issue title for \" + String.join(\", \", issue.issuesWithLeadingLowerCaseLetter()));\n        }\n        addMessage(issue.check(), String.join(\"\\n\", messages),\n                issue.severity());\n    }\n\n    @Override\n    public void visit(CopyrightFormatIssue issue) {\n        List<String> messages = new ArrayList<>();\n        for (var entry : issue.filesWithCopyrightFormatIssue().entrySet()) {\n            messages.add(\"Found copyright format issue for \" + entry.getKey() + \" in [\" + String.join(\", \", entry.getValue()) + \"]\");\n        }\n        for (var entry : issue.filesWithCopyrightMissingIssue().entrySet()) {\n            messages.add(\"Can't find copyright header for \" + entry.getKey() + \" in [\" + String.join(\", \", entry.getValue()) + \"]\");\n        }\n        addMessage(issue.check(), String.join(\"\\n\", messages),\n                issue.severity());\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestCommandWorkItem.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport java.util.logging.Level;\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.*;\n\nimport java.io.*;\nimport java.util.*;\nimport java.util.function.Consumer;\nimport java.util.logging.Logger;\nimport java.util.regex.*;\nimport java.util.stream.*;\n\nimport static org.openjdk.skara.bots.pr.CommitCommandWorkItem.COMMAND_REPLY_MARKER;\nimport static org.openjdk.skara.bots.pr.CommitCommandWorkItem.COMMAND_REPLY_PATTERN;\n\npublic class PullRequestCommandWorkItem extends PullRequestWorkItem {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n    static final String VALID_BOT_COMMAND_MARKER = \"<!-- Valid self-command -->\";\n\n    PullRequestCommandWorkItem(PullRequestBot bot, String prId, Consumer<RuntimeException> errorHandler,\n            ZonedDateTime prUpdatedAt, boolean needsReadyCheck) {\n        super(bot, prId, errorHandler, prUpdatedAt, needsReadyCheck);\n    }\n\n    private static class InvalidBodyCommandHandler implements CommandHandler {\n        @Override\n        public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n            reply.println(\"The command `\" + command.name() + \"` cannot be used in the pull request body. Please use it in a new comment.\");\n        }\n\n        @Override\n        public String description() {\n            return \"\";\n        }\n\n        @Override\n        public String name() {\n            return \"invalidCommand\";\n        }\n    }\n\n    private Optional<CommandInvocation> nextCommand(PullRequest pr, List<Comment> comments) {\n        var allCommands = findAllCommands(pr, comments);\n        var handled = findHandledCommands(pr, comments);\n        return allCommands.stream()\n                          .filter(ci -> !handled.contains(ci.id()))\n                          .filter(ci -> !bot.externalPullRequestCommands().containsKey(ci.name()))\n                          .findFirst();\n    }\n\n    static List<CommandInvocation> findAllCommands(PullRequest pr, List<Comment> comments) {\n        var self = pr.repository().forge().currentUser();\n        var body = PullRequestBody.parse(pr).bodyText();\n        return Stream.concat(CommandExtractor.extractCommands(body, \"body\", pr.author(), pr.createdAt()).stream(),\n                        comments.stream()\n                                .filter(comment -> !comment.author().equals(self) || comment.body().endsWith(VALID_BOT_COMMAND_MARKER))\n                                .flatMap(c -> CommandExtractor.extractCommands(c.body(), c.id(), c.author(), c.createdAt()).stream()))\n                .collect(Collectors.toList());\n    }\n\n    static Set<String> findHandledCommands(PullRequest pr, List<Comment> comments) {\n        var self = pr.repository().forge().currentUser();\n        return comments.stream()\n                .filter(comment -> comment.author().equals(self))\n                .map(comment -> COMMAND_REPLY_PATTERN.matcher(comment.body()))\n                .filter(Matcher::find)\n                .map(matcher -> matcher.group(1))\n                .collect(Collectors.toSet());\n    }\n\n    private void changeLabelsAfterComment(List<String> labelsToAdd, List<String> labelsToRemove){\n        if (labelsToAdd != null && !labelsToAdd.isEmpty()) {\n            for (var label : labelsToAdd) {\n                if (!pr.labelNames().contains(label)) {\n                    log.info(\"Adding \" + label + \" label to \" + describe(pr));\n                    pr.addLabel(label);\n                }\n            }\n        }\n        if (labelsToRemove != null && !labelsToRemove.isEmpty()) {\n            for (var label : labelsToRemove) {\n                if (pr.labelNames().contains(label)) {\n                    log.info(\"Removing \" + label + \" label from \" + describe(pr));\n                    pr.removeLabel(label);\n                }\n            }\n        }\n    }\n\n    private String describe(PullRequest pr) {\n        return pr.repository().name() + \"#\" + prId;\n    }\n\n    private void processCommand(PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments,\n                                boolean isCommit) {\n        var writer = new StringWriter();\n        var printer = new PrintWriter(writer);\n\n        printer.println(String.format(COMMAND_REPLY_MARKER, command.id()));\n        printer.print(\"@\");\n        printer.print(command.user().username());\n        printer.print(\" \");\n\n        var handler = command.handler();\n        if (handler.isPresent()) {\n            if (isCommit) {\n                if (handler.get().allowedInCommit()) {\n                    var hash = pr.findIntegratedCommitHash();\n                    if (hash.isPresent()) {\n                        var commit = pr.repository().commit(hash.get()).orElseThrow();\n                        handler.get().handle(bot, commit, censusInstance, scratchArea, command, allComments, printer);\n                    } else {\n                        // FIXME the argument `isCommit` is true here, which means the PR already has the `integrated` label\n                        //  and has the integrated commit hash, so this branch would never be run.\n                        //  Maybe this branch could be removed. And this branch can not be tested now.\n                        printer.print(\"The command `\");\n                        printer.print(command.name());\n                        printer.println(\"` can only be used in a pull request that has been integrated.\");\n                    }\n                } else {\n                    printer.print(\"The command `\");\n                    printer.print(command.name());\n                    printer.println(\"` can only be used in open pull requests.\");\n                }\n            } else {\n                if (handler.get().allowedInPullRequest()) {\n                    if (command.id().startsWith(\"body\") && !handler.get().allowedInBody()) {\n                        handler = Optional.of(new PullRequestCommandWorkItem.InvalidBodyCommandHandler());\n                    }\n                    var labelsToAdd = new ArrayList<String>();\n                    var labelsToRemove = new ArrayList<String>();\n                    handler.get().handle(bot, pr, censusInstance, scratchArea, command, allComments, printer, labelsToAdd, labelsToRemove);\n                    var newComment = pr.addComment(writer.toString());\n                    var latency = Duration.between(command.createdAt(), newComment.createdAt());\n                    log.log(Level.INFO, \"Time from command '\" + command.name() + \"' to reply \" + latency, latency);\n                    changeLabelsAfterComment(labelsToAdd, labelsToRemove);\n                    return;\n                } else {\n                    printer.print(\"The command `\");\n                    printer.print(command.name());\n                    printer.println(\"` can not be used in pull requests.\");\n                }\n            }\n        } else {\n            printer.print(\"Unknown command `\");\n            printer.print(command.name());\n            printer.println(\"` - for a list of valid commands use `/help`.\");\n        }\n\n        var newComment = pr.addComment(writer.toString());\n        var latency = Duration.between(command.createdAt(), newComment.createdAt());\n        log.log(Level.INFO, \"Time from command '\" + command.name() + \"' to reply \" + latency, latency);\n    }\n\n    @Override\n    public Collection<WorkItem> prRun(ScratchArea scratchArea) {\n        log.info(\"Looking for PR commands\");\n\n        var comments = getAllComments();\n        var nextCommand = nextCommand(pr, comments);\n\n        if (nextCommand.isEmpty()) {\n            log.info(\"No new non-external PR commands found, stopping further processing\");\n\n            // If there is no label configuration, don't generate LabelerWorkItem\n            if (bot.labelConfiguration().allowed().isEmpty()) {\n                return List.of();\n            }\n\n            if (!pr.isClosed()) {\n                // Check if the headHash of the pr has already been processed\n                var autoLabeledHashOpt = LabelerWorkItem.autoLabeledHash(prComments(), pr);\n                if (autoLabeledHashOpt.isPresent() && autoLabeledHashOpt.get().equals(pr.headHash().hex())) {\n                    return List.of();\n                }\n                return List.of(new LabelerWorkItem(bot, prId, errorHandler, triggerUpdatedAt));\n            }\n\n            return List.of();\n        }\n\n        var seedPath = bot.seedStorage().orElse(scratchArea.getSeeds());\n        var hostedRepositoryPool = new HostedRepositoryPool(seedPath);\n\n        CensusInstance census;\n        var command = nextCommand.get();\n\n        try {\n            census = CensusInstance.createCensusInstance(hostedRepositoryPool, bot.censusRepo(), bot.censusRef(), scratchArea.getCensus(), pr,\n                    bot.confOverrideRepository().orElse(null), bot.confOverrideName(), bot.confOverrideRef());\n        } catch (InvalidJCheckConfException | MissingJCheckConfException e) {\n            String errorMessage;\n            if (e instanceof InvalidJCheckConfException) {\n                errorMessage = \"invalid\";\n            } else {\n                errorMessage = \"missing\";\n            }\n\n            var writer = new StringWriter();\n            var printer = new PrintWriter(writer);\n\n            printer.println(String.format(COMMAND_REPLY_MARKER, command.id()));\n            printer.print(\"@\");\n            printer.print(command.user().username());\n            printer.print(\" \");\n            if (bot.confOverrideRepository().isEmpty()) {\n                var branchNames = pr.repository().branches().stream().map(HostedBranch::name).toList();\n                if (branchNames.contains(pr.targetRef())) {\n                    printer.print(\"JCheck configuration is \" + errorMessage + \" in the target branch of this pull request.\");\n                } else {\n                    printer.print(\"The target branch of this pull request no longer exists. Please retarget this pull request.\");\n                }\n            } else {\n                log.severe(bot.confOverrideName() + \" on \" + bot.confOverrideRef() +\n                        \" is \" + errorMessage + \" in repo \" + bot.confOverrideRepository().get().name());\n                printer.print(\"The JCheck configuration has been overridden, \" +\n                        \"but is \" + errorMessage + \". Skara admins have been notified.\");\n            }\n            printer.print(\" Please issue this command again once the problem has been resolved.\");\n            pr.addComment(writer.toString());\n            return List.of();\n        }\n        log.info(\"Processing command: \" + command.id() + \" - \" + command.name());\n\n        // We can't trust just the integrated label as that gets set before the commit comment.\n        // If marked as integrated but there is no commit comment, any integrate command needs\n        // to run again to correct the state of the PR.\n        if (!pr.labelNames().contains(\"integrated\") || pr.findIntegratedCommitHash().isEmpty()) {\n            processCommand(pr, census, scratchArea, command, comments, false);\n            // Run another check to reflect potential changes from commands\n            return List.of(CheckWorkItem.fromWorkItem(bot, prId, errorHandler, triggerUpdatedAt));\n        } else {\n            processCommand(pr, census, scratchArea, command, comments, true);\n            return List.of();\n        }\n    }\n\n    @Override\n    public String toString() {\n        return \"PullRequestCommandWorkItem@\" + bot.repo().name() + \"#\" + prId;\n    }\n\n    @Override\n    public String workItemName() {\n        return \"pr-command\";\n    }\n\n    /**\n     * This method returns all the comments in the pr including comments in reviews(review body)\n     */\n    private List<Comment> getAllComments() {\n        return Stream.concat(prComments().stream(),\n                        pr.reviews().stream().map(review -> new Comment(\"Review\" + review.id(), review.body().orElse(\"\"), review.reviewer(), review.createdAt(), null)))\n                .sorted(Comparator.comparing(Comment::createdAt)).toList();\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestWorkItem.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport java.util.Collection;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.logging.Level;\nimport java.util.logging.Logger;\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.forge.PullRequest;\n\nimport java.util.function.Consumer;\nimport org.openjdk.skara.issuetracker.Comment;\n\nabstract class PullRequestWorkItem implements WorkItem {\n    private static final Logger log = Logger.getLogger(PullRequestWorkItem.class.getName());\n    final Consumer<RuntimeException> errorHandler;\n    final PullRequestBot bot;\n    final String prId;\n    private final boolean needsReadyCheck;\n    /**\n     * The updatedAt timestamp of the external entity that triggered this WorkItem,\n     * which would be either a PR or a CSR Issue. Used for tracking reaction latency\n     * of the bot through logging. This is the best estimated value, which is the last\n     * updatedAt value when the bot finds the PR or CSR Issue. This value is propagated\n     * through chains of WorkItems, as the complete chain is considered to have\n     * been triggered by the same trigger.\n     */\n    final ZonedDateTime triggerUpdatedAt;\n    PullRequest pr;\n    private List<Comment> comments;\n\n    PullRequestWorkItem(PullRequestBot bot, String prId, Consumer<RuntimeException> errorHandler,\n            ZonedDateTime triggerUpdatedAt, boolean needsReadyCheck) {\n        this.bot = bot;\n        this.prId = prId;\n        this.errorHandler = errorHandler;\n        this.triggerUpdatedAt = triggerUpdatedAt;\n        this.needsReadyCheck = needsReadyCheck;\n    }\n\n    @Override\n    public final boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof PullRequestWorkItem otherItem)) {\n            return true;\n        }\n        if (!(prId.equals(otherItem.prId) && bot.repo().isSame(otherItem.bot.repo()))) {\n            return true;\n        }\n        return false;\n    }\n\n    private boolean isReady() {\n        if (!needsReadyCheck) {\n            return true;\n        }\n        var labels = new HashSet<>(pr.labelNames());\n        for (var readyLabel : bot.readyLabels()) {\n            if (!labels.contains(readyLabel)) {\n                log.fine(\"PR is not yet ready - missing label '\" + readyLabel + \"'\");\n                return false;\n            }\n        }\n\n        var comments = prComments();\n        for (var readyComment : bot.readyComments().entrySet()) {\n            var commentFound = false;\n            for (var comment : comments) {\n                if (comment.author().username().equals(readyComment.getKey())) {\n                    var matcher = readyComment.getValue().matcher(comment.body());\n                    if (matcher.find()) {\n                        commentFound = true;\n                        break;\n                    }\n                }\n            }\n            if (!commentFound) {\n                log.fine(\"PR is not yet ready - missing ready comment from '\" + readyComment.getKey() +\n                        \"containing '\" + readyComment.getValue().pattern() + \"'\");\n                return false;\n            }\n        }\n        return true;\n    }\n\n    /**\n     * Loads the PR from the remote repo at the start of run to guarantee that all\n     * PullRequestWorkItems have a coherent and current view of the PR to avoid\n     * races. When the run method is called, we are guaranteed to be the only\n     * WorkItem executing on this specific PR through the concurrentWith method.\n     * <p>\n     * Subclasses should override prRun instead of this method.\n     */\n    @Override\n    public final Collection<WorkItem> run(Path scratchPath) {\n        pr = bot.repo().pullRequest(prId);\n        // Check if PR is ready to be evaluated at all.\n        if (!isReady()) {\n            return List.of();\n        }\n        ScratchArea scratchArea = new ScratchArea(scratchPath, bot.name());\n        return prRun(scratchArea);\n    }\n\n    /**\n     * Lazy fetching of pr comments to avoid multiple fetch calls.\n     */\n    protected List<Comment> prComments() {\n        if (comments == null) {\n            comments = pr.comments();\n        }\n        return comments;\n    }\n\n    abstract Collection<WorkItem> prRun(ScratchArea scratchArea);\n\n    @Override\n    public final void handleRuntimeException(RuntimeException e) {\n        errorHandler.accept(e);\n    }\n\n    @Override\n    public String botName() {\n        return bot.name();\n    }\n\n    /**\n     * Logs a latency message. Meant to be used right before returning from prRun(),\n     * if it makes sense to log a message at that point.\n     * @param message Message to be logged, will get latency string added to it.\n     * @param endTime The end time to use to calculate latency\n     * @param log The logger to log to\n     */\n    protected void logLatency(String message, ZonedDateTime endTime, Logger log) {\n        var latency = Duration.between(triggerUpdatedAt, endTime);\n        log.log(Level.INFO, message + latency, latency);\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/ReadyForSponsorTracker.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.bots.common.PullRequestConstants;\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.util.*;\nimport java.util.regex.*;\nimport java.util.stream.Collectors;\n\nclass ReadyForSponsorTracker {\n\n    static String addIntegrationMarker(Hash hash) {\n        return String.format(PullRequestConstants.READY_FOR_SPONSOR_MARKER, hash.hex());\n    }\n\n    static Optional<Hash> latestReadyForSponsor(HostUser botUser, List<Comment> comments) {\n        var ready = comments.stream()\n                .filter(comment -> comment.author().equals(botUser))\n                .map(comment -> PullRequestConstants.READY_FOR_SPONSOR_MARKER_PATTERN.matcher(comment.body()))\n                .filter(Matcher::find)\n                .map(matcher -> matcher.group(1))\n                .map(Hash::new)\n                .collect(Collectors.toList());\n        if (ready.size() == 0) {\n            return Optional.empty();\n        } else {\n            return Optional.of(ready.getLast());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/ReviewCoverage.java",
    "content": "/*\n * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport java.io.IOException;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.logging.Level;\nimport java.util.logging.Logger;\n\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.forge.PullRequestUtils;\nimport org.openjdk.skara.forge.Review;\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.Repository;\n\npublic class ReviewCoverage {\n\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n    private final boolean useStaleReviews;\n    private final boolean acceptSimpleMerges;\n    private final Repository repo;\n    private final PullRequest pr;\n    private final Map<Review, Boolean> cachedCoverage = new HashMap<>();\n    private Hash cachedTargetHash;\n\n    public ReviewCoverage(boolean useStaleReviews,\n                          boolean acceptSimpleMerges,\n                          Repository repo,\n                          PullRequest pr) {\n        this.useStaleReviews = useStaleReviews;\n        this.acceptSimpleMerges = acceptSimpleMerges;\n        this.repo = repo;\n        this.pr = pr;\n    }\n\n    public boolean covers(Review review) {\n        return cachedCoverage.computeIfAbsent(review, this::covers0);\n    }\n\n    private boolean covers0(Review review) {\n        var r = review.hash();\n        // Reviews without a hash are never valid as they referred to no longer\n        // existing commits.\n        if (r.isEmpty() || review.verdict() != Review.Verdict.APPROVED\n                || !review.targetRef().equals(pr.targetRef())) {\n            return false;\n        }\n        if (useStaleReviews || r.get().equals(pr.headHash())) {\n            return true;\n        }\n        if (!acceptSimpleMerges) {\n            return false;\n        }\n        boolean seenAtLeastOneCommit = false;\n        try {\n            try (var commits = repo.commits(List.of(pr.headHash()), List.of(r.get(), targetHash()))) {\n                for (var c : commits) {\n                    seenAtLeastOneCommit = true;\n                    if (!c.isMerge() || c.numParents() != 2) {\n                        return false;\n                    }\n                    // from https://git-scm.com/book/en/v2/Git-Tools-Revision-Selection\n                    // we expect that ^1 has to belong to the PR and ^2 to the target\n                    // branch; the former seems obvious and enforced by Git, while\n                    // the latter should be checked\n                    var secondParent = c.parents().get(1);\n                    if (!repo.isAncestor(secondParent, targetHash())) {\n                        return false;\n                    }\n                    if (!repo.isRemergeDiffEmpty(c.hash())) {\n                        return false;\n                    }\n                }\n            }\n        } catch (IOException e) {\n            log.log(Level.FINE, \"Error while looking for simple merges: \" + pr.repository() + \", \" + pr.id(), e);\n            return false;\n        }\n        if (seenAtLeastOneCommit) {\n            log.finest(\"Saved a merge from review: \" + pr.repository() + \", \" + pr.id());\n        }\n        return seenAtLeastOneCommit;\n    }\n\n    private Hash targetHash() throws IOException {\n        if (cachedTargetHash == null) {\n            cachedTargetHash = PullRequestUtils.targetHash(repo);\n        } else {\n            // main assumption for caching targetHash\n            if (ReviewCoverage.class.desiredAssertionStatus()) {\n                var latest = PullRequestUtils.targetHash(repo);\n                assert cachedTargetHash.equals(latest) :\n                        cachedTargetHash + \" != \" + latest;\n            }\n        }\n        return cachedTargetHash;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/ReviewerCommand.java",
    "content": "/*\n * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.census.Contributor;\nimport org.openjdk.skara.census.Namespace;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.io.PrintWriter;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.reviewer;\n\npublic class ReviewerCommand implements CommandHandler {\n    private static final Pattern COMMAND_PATTERN = Pattern.compile(\"^(credit|remove)\\\\s+(.+)$\");\n\n    private void showHelp(PullRequest pr, PrintWriter reply) {\n        reply.println(\"Syntax: `/reviewer (credit|remove) [@user | openjdk-user]+`. For example:\");\n        reply.println();\n        reply.println(\" * `/reviewer credit @openjdk-bot`\");\n        reply.println(\" * `/reviewer credit duke`\");\n        reply.println(\" * `/reviewer credit @user1 @user2`\");\n    }\n\n    private Optional<Contributor> parseUser(String user, PullRequest pr, CensusInstance censusInstance) {\n        user = user.strip();\n        if (user.isEmpty()) {\n            return Optional.empty();\n        }\n        Contributor contributor;\n        if (user.charAt(0) == '@') {\n            var platformUser = pr.repository().forge().user(user.substring(1));\n            if (platformUser.isEmpty()) {\n                return Optional.empty();\n            }\n            contributor = censusInstance.namespace().get(platformUser.get().id());\n            if (contributor == null) {\n                return Optional.empty();\n            }\n        } else {\n            contributor = censusInstance.census().contributor(user);\n            if (contributor == null) {\n                return Optional.empty();\n            }\n        }\n        return Optional.of(contributor);\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        if (!command.user().equals(pr.author())) {\n            reply.println(\"Only the author (@\" + pr.author().username() + \") is allowed to issue the `reviewer` command.\");\n            return;\n        }\n\n        var matcher = COMMAND_PATTERN.matcher(command.args());\n        if (!matcher.matches()) {\n            showHelp(pr, reply);\n            return;\n        }\n\n        var reviewers = new ArrayList<Contributor>();\n        for (var entry : matcher.group(2).split(\"[\\\\s,]+\")) {\n            var reviewer = parseUser(entry, pr, censusInstance);\n            if (reviewer.isEmpty()) {\n                reply.println(\"Could not parse `\" + entry + \"` as a valid reviewer.\");\n                showHelp(pr, reply);\n                return;\n            }\n\n            reviewers.add(reviewer.get());\n        }\n\n        var namespace = censusInstance.namespace();\n        var authenticatedReviewers = CheckablePullRequest.reviewerNames(pr.reviews(), namespace);\n        var action = matcher.group(1);\n        if (action.equals(\"credit\")) {\n            for (var reviewer : reviewers) {\n                if (!authenticatedReviewers.contains(reviewer.username())) {\n                    reply.println(Reviewers.addReviewerMarker(reviewer));\n                    reply.println(\"Reviewer `\" + reviewer.username() + \"` successfully credited.\");\n                } else {\n                    if (hasMadeAuthenticatedApproveReview(pr.reviews(), reviewer, namespace)) {\n                        reply.println(\"Reviewer `\" + reviewer.username() + \"` has already made an authenticated review of this PR, and does not need to be credited manually.\");\n                    } else {\n                        reply.println(\"Reviewer `\" + reviewer.username() + \"` has already made an authenticated review of this PR, but did not approve it. Manually crediting them is not allowed.\");\n                    }\n                }\n            }\n        } else if (action.equals(\"remove\")) {\n            var failed = false;\n            var existing = new HashSet<>(Reviewers.reviewers(pr.repository().forge().currentUser(), allComments));\n            for (var reviewer : reviewers) {\n                if (existing.contains(reviewer.username())) {\n                    reply.println(Reviewers.removeReviewerMarker(reviewer));\n                    reply.println(\"Reviewer `\" + reviewer.username() + \"` successfully removed.\");\n                } else {\n                    if (existing.isEmpty()) {\n                        reply.println(\"There are no manually specified reviewers associated with this pull request.\");\n                        failed = true;\n                    } else {\n                        reply.println(\"Reviewer `\" + reviewer.username() + \"` was not found.\");\n                        failed = true;\n                    }\n                }\n            }\n\n            if (failed) {\n                reply.println(\"Current credited reviewers are:\");\n                for (var e : existing) {\n                    reply.println(\"- `\" + e + \"`\");\n                }\n            }\n        }\n    }\n\n    private boolean hasMadeAuthenticatedApproveReview(List<Review> reviews, Contributor reviewer, Namespace namespace) {\n        return reviews.stream()\n                .filter(review -> review.verdict().equals(Review.Verdict.APPROVED))\n                .anyMatch(review -> namespace.get(review.reviewer().id()).username().equals(reviewer.username()));\n    }\n\n    @Override\n    public String description() {\n        return \"manage additional reviewers for a PR\";\n    }\n\n    @Override\n    public String name() {\n        return reviewer.name();\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/Reviewers.java",
    "content": "/*\n * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.census.Contributor;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.util.*;\nimport java.util.regex.*;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\n\nclass Reviewers {\n    private static final String ADD_MARKER = \"<!-- add reviewer: '%s' -->\";\n    private static final String REMOVE_MARKER = \"<!-- remove reviewer: '%s' -->\";\n    private static final Pattern MARKER_PATTERN = Pattern.compile(\"<!-- (add|remove) reviewer: '(.*?)' -->\");\n\n    static String addReviewerMarker(Contributor contributor) {\n        return String.format(ADD_MARKER, contributor.username());\n    }\n\n    static String addReviewerMarker(String username) {\n        return String.format(ADD_MARKER, username);\n    }\n\n    static String removeReviewerMarker(Contributor contributor) {\n        return String.format(REMOVE_MARKER, contributor.username());\n    }\n\n    static List<String> reviewers(HostUser botUser, List<Comment> comments) {\n        var reviewerActions = comments.stream()\n                                         .filter(comment -> comment.author().equals(botUser))\n                                         .flatMap(comment -> Stream.of(comment.body().split(\"\\n\")))\n                                         .map(line -> MARKER_PATTERN.matcher(line))\n                                         .filter(Matcher::find)\n                                         .collect(Collectors.toList());\n        var contributors = new LinkedHashSet<String>();\n        for (var action : reviewerActions) {\n            switch (action.group(1)) {\n                case \"add\":\n                    contributors.add(action.group(2));\n                    break;\n                case \"remove\":\n                    contributors.remove(action.group(2));\n                    break;\n            }\n        }\n\n        return new ArrayList<>(contributors);\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/ReviewersCommand.java",
    "content": "/*\n * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.io.PrintWriter;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\nimport static org.openjdk.skara.jcheck.ReviewersConfiguration.BYLAWS_URL;\nimport static org.openjdk.skara.bots.common.CommandNameEnum.reviewers;\n\npublic class ReviewersCommand implements CommandHandler {\n    private static final Map<String, String> ROLE_MAPPINGS = Map.of(\n            \"lead\", \"lead\",\n            \"reviewers\", \"reviewers\",\n            \"reviewer\", \"reviewers\",\n            \"committers\", \"committers\",\n            \"committer\", \"committers\",\n            \"authors\", \"authors\",\n            \"author\", \"authors\",\n            \"contributors\", \"contributors\",\n            \"contributor\", \"contributors\");\n\n    private void showHelp(PrintWriter reply) {\n        reply.println(\"Usage: `/reviewers <n> [<role>]` where `<n>` is the number of required reviewers. \" +\n                              \"If role is set, the reviewers need to have that project role. If omitted, role defaults to `authors`.\");\n    }\n\n    private static boolean roleIsLower(String updated, String existing) {\n        if (existing.equals(\"lead\")) {\n            return !updated.equals(\"lead\");\n        }\n        if (existing.equals(\"reviewers\")) {\n            return !updated.equals(\"lead\") &&\n                   !updated.equals(\"reviewers\");\n        }\n        if (existing.equals(\"committers\")) {\n            return !updated.equals(\"lead\") &&\n                   !updated.equals(\"reviewers\") &&\n                   !updated.equals(\"committers\");\n        }\n        if (existing.equals(\"authors\")) {\n            return !updated.equals(\"lead\") &&\n                   !updated.equals(\"reviewers\") &&\n                   !updated.equals(\"committers\") &&\n                   !updated.equals(\"authors\");\n        }\n        if (existing.equals(\"contributors\")) {\n            return false;\n        }\n        throw new IllegalArgumentException(\"Unexpected existing role: \" + existing);\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        if (!pr.author().equals(command.user()) && !censusInstance.isReviewer(command.user())) {\n            reply.println(\"Only the author of the pull request or [Reviewers](https://openjdk.org/bylaws#reviewer) are allowed to change the number of required reviewers.\");\n            return;\n        }\n\n        var splitArgs = command.args().split(\" \");\n        if (splitArgs.length < 1 || splitArgs.length > 2) {\n            showHelp(reply);\n            return;\n        }\n\n        int numReviewers;\n        try {\n            numReviewers = Integer.parseInt(splitArgs[0]);\n        } catch (NumberFormatException e) {\n            showHelp(reply);\n            return;\n        }\n        if (numReviewers > 10) {\n            showHelp(reply);\n            reply.println(\"Cannot increase the required number of reviewers above 10 (requested: \" + numReviewers + \")\");\n            return;\n        }\n        if (numReviewers < 0) {\n            showHelp(reply);\n            reply.println(\"Cannot decrease the required number of reviewers below 0 (requested: \" + numReviewers + \")\");\n            return;\n        }\n\n        String role = \"authors\";\n        if (splitArgs.length > 1) {\n            if (!ROLE_MAPPINGS.containsKey(splitArgs[1].toLowerCase())) {\n                showHelp(reply);\n                reply.println(\"Unknown role `\" + splitArgs[1] + \"` specified.\");\n                return;\n            }\n            role = ROLE_MAPPINGS.get(splitArgs[1].toLowerCase());\n        }\n\n        if (pr.author().equals(command.user()) && !censusInstance.isReviewer(command.user())) {\n            var user = pr.repository().forge().currentUser();\n            var previous = ReviewersTracker.additionalRequiredReviewers(user, allComments);\n            if (previous.isPresent()) {\n                if (roleIsLower(role, previous.get().role())) {\n                    reply.println(\"Only [Reviewers](https://openjdk.org/bylaws#reviewer) are allowed to lower the role for additional reviewers.\");\n                    return;\n                }\n                if (numReviewers < previous.get().number()) {\n                    reply.println(\"Only [Reviewers](https://openjdk.org/bylaws#reviewer) are allowed to decrease the number of required reviewers.\");\n                    return;\n                }\n            }\n        }\n\n        var updatedLimits = ReviewersTracker.updatedRoleLimits(censusInstance.configuration(), numReviewers, role);\n        // The role name of the configuration should be changed to the official role name.\n        var formatLimits = new LinkedHashMap<String, Integer>();\n        formatLimits.put(\"[Lead%s](%s#project-lead)\", updatedLimits.get(\"lead\"));\n        formatLimits.put(\"[Reviewer%s](%s#reviewer)\", updatedLimits.get(\"reviewers\"));\n        formatLimits.put(\"[Committer%s](%s#committer)\", updatedLimits.get(\"committers\"));\n        formatLimits.put(\"[Author%s](%s#author)\", updatedLimits.get(\"authors\"));\n        formatLimits.put(\"[Contributor%s](%s#contributor)\", updatedLimits.get(\"contributors\"));\n\n        reply.println(ReviewersTracker.setReviewersMarker(numReviewers, role));\n        var totalRequired = formatLimits.values().stream().mapToInt(Integer::intValue).sum();\n        if (pr.labelNames().contains(\"clean\") && pr.labelNames().contains(\"backport\")) {\n            reply.println(\"Warning: By issuing the /reviewers command in this clean backport pull request, the reviewers check has now been enabled.\");\n        }\n        reply.print(\"The total number of required reviews for this PR (including the jcheck configuration \" +\n                    \"and the last /reviewers command) is now set to \" + totalRequired);\n\n        // Create a helpful message regarding the required distribution (if applicable)\n        var nonZeroDescriptions = formatLimits.entrySet().stream()\n                .filter(entry -> entry.getValue() > 0)\n                .map(entry -> entry.getValue() + \" \" + String.format(entry.getKey(), entry.getValue() > 1 ? \"s\" : \"\", BYLAWS_URL))\n                .collect(Collectors.toList());\n        if (nonZeroDescriptions.size() > 0) {\n            reply.print(\" (with at least \" + String.join(\", \", nonZeroDescriptions) + \")\");\n        }\n\n        reply.println(\".\");\n    }\n\n    @Override\n    public String description() {\n        return \"set the number of additional required reviewers for this PR\";\n    }\n\n    @Override\n    public String name() {\n        return reviewers.name();\n    }\n\n    @Override\n    public boolean allowedInBody() {\n        return true;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/ReviewersTracker.java",
    "content": "/*\n * Copyright (c) 2020, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.jcheck.JCheckConfiguration;\n\nimport java.util.*;\nimport java.util.regex.*;\n\nclass ReviewersTracker {\n    static final Source DEFAULT_SOURCE = Source.USER;\n    private static final String REVIEWERS_MARKER = \"<!-- additional required reviewers id marker (%d) (%s) (%s)-->\";\n    private static final Pattern REVIEWERS_MARKER_PATTERN = Pattern.compile(\n            \"<!-- additional required reviewers id marker \\\\((\\\\d+)\\\\) \\\\((\\\\w+)\\\\)(?: \\\\(([^)]*)\\\\))?\\\\s*-->\");\n\n    static String setReviewersMarker(int numReviewers, String role) {\n        return setReviewersMarker(numReviewers, role, DEFAULT_SOURCE);\n    }\n\n    static String setReviewersMarker(int numReviewers, String role, Source source) {\n        return String.format(REVIEWERS_MARKER, numReviewers, role, source.value());\n    }\n\n    static LinkedHashMap<String, Integer> updatedRoleLimits(JCheckConfiguration checkConfiguration, int count, String role) {\n        var currentReviewers = checkConfiguration.checks().reviewers();\n\n        var updatedLimits = new LinkedHashMap<String, Integer>();\n        updatedLimits.put(\"lead\", currentReviewers.lead());\n        updatedLimits.put(\"reviewers\", currentReviewers.reviewers());\n        updatedLimits.put(\"committers\", currentReviewers.committers());\n        updatedLimits.put(\"authors\", currentReviewers.authors());\n        updatedLimits.put(\"contributors\", currentReviewers.contributors());\n\n        // Increase the required role level by the requested amount (while subtracting higher roles)\n        var remainingAdditional = count;\n        var remainingRemovals = 0;\n        var roles = new ArrayList<>(updatedLimits.keySet());\n        for (var r : roles) {\n            if (!r.equals(role)) {\n                remainingAdditional -= updatedLimits.get(r);\n                if (remainingAdditional <= 0) {\n                    break;\n                }\n            } else {\n                // The new value cannot be lower than the value in '.jcheck/conf',\n                // because the '.jcheck/conf' file means the minimal reviewer requirement.\n                if (remainingAdditional > updatedLimits.get(r)) {\n                    // Set the number for the lower roles to remove.\n                    remainingRemovals = remainingAdditional - updatedLimits.get(r);\n                    updatedLimits.replace(r, remainingAdditional);\n                }\n                break;\n            }\n        }\n\n        if (remainingRemovals == 0) {\n            // Improve performance. If remainingRemovals is 0, don't need to decrease the lower roles.\n            return updatedLimits;\n        }\n\n        // Decrease lower roles (if any) to avoid increasing the total count above the requested\n        Collections.reverse(roles);\n        for (var r : roles) {\n            if (!r.equals(role)) {\n                var originalVal = updatedLimits.get(r);\n                var removed = Math.max(0, originalVal - remainingRemovals);\n                updatedLimits.replace(r, removed);\n                remainingRemovals -= (originalVal - removed);\n            } else {\n                break;\n            }\n        }\n\n        return updatedLimits;\n    }\n\n    enum Source {\n        USER(\"user\"),\n        BOT(\"bot\");\n\n        private final String value;\n\n        Source(String value) {\n            this.value = value;\n        }\n\n        String value() {\n            return value;\n        }\n\n        static Source fromValue(String value) {\n            return Arrays.stream(values())\n                    .filter(source -> source.value.equals(value))\n                    .findFirst()\n                    .orElse(USER);\n        }\n    }\n\n    static class AdditionalRequiredReviewers {\n        private int number;\n        private String role;\n        private Source source;\n\n        AdditionalRequiredReviewers(int number, String role) {\n            this(number, role, DEFAULT_SOURCE);\n        }\n\n        AdditionalRequiredReviewers(int number, String role, Source source) {\n            this.number = number;\n            this.role = role;\n            this.source = source;\n        }\n\n        int number() {\n            return number;\n        }\n\n        String role() {\n            return role;\n        }\n\n        Source source() {\n            return source;\n        }\n    }\n\n    static Optional<AdditionalRequiredReviewers> additionalRequiredReviewers(HostUser botUser, List<Comment> comments) {\n        var reviewersActions = comments.stream()\n                                       .filter(comment -> comment.author().equals(botUser))\n                                       .map(comment -> REVIEWERS_MARKER_PATTERN.matcher(comment.body()))\n                                       .filter(Matcher::find)\n                                       .toList();\n        if (reviewersActions.isEmpty()) {\n            return Optional.empty();\n        }\n        var last = reviewersActions.getLast();\n        var sourceValue = last.group(3);\n        var source = sourceValue == null || sourceValue.isBlank()\n                ? DEFAULT_SOURCE\n                : Source.fromValue(sourceValue);\n        return Optional.of(new AdditionalRequiredReviewers(Integer.parseInt(last.group(1)), last.group(2), source));\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/ScratchArea.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.HostedRepository;\n\nimport java.nio.file.Path;\n\npublic class ScratchArea {\n    private final Path root;\n    private final String botName;\n\n    public ScratchArea(Path root, String botName) {\n        this.root = root;\n        this.botName = botName;\n    }\n\n    /**\n     *  Return a global repository path for this repository\n     */\n    public Path get(HostedRepository repo) {\n        return root.resolve(botName).resolve(\"repos\").resolve(repo.name());\n    }\n\n    /**\n     *  Return a path suitable for this command\n     */\n    public Path get(CommandHandler commandHandler) {\n        return root.resolve(botName).resolve(\"command\").resolve(commandHandler.name());\n    }\n\n    public Path getSeeds() {\n        return root.resolve(\"seeds\");\n    }\n\n    public Path getCensus() {\n        return root.resolve(\"census\");\n    }\n\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/SponsorCommand.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.io.*;\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.logging.Level;\nimport java.util.logging.Logger;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.sponsor;\n\npublic class SponsorCommand implements CommandHandler {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.pr\");\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        if (censusInstance.isCommitter(pr.author())) {\n            reply.println(\"This change does not need sponsoring - the author is allowed to integrate it.\");\n            return;\n        }\n        if (!censusInstance.isCommitter(command.user())) {\n            reply.println(\"Only [Committers](https://openjdk.org/bylaws#committer) are allowed to sponsor changes.\");\n            return;\n        }\n\n        Optional<Hash> prePushHash = IntegrateCommand.checkForPrePushHash(bot, pr, scratchArea, allComments);\n        if (prePushHash.isPresent()) {\n            markIntegratedAndClosed(pr, prePushHash.get(), reply, allComments);\n            return;\n        }\n\n        var readyHash = ReadyForSponsorTracker.latestReadyForSponsor(pr.repository().forge().currentUser(), allComments);\n        if (readyHash.isEmpty()) {\n            if (!pr.labelNames().contains(\"auto\")) {\n                reply.println(\"The change author (@\" + pr.author().username() + \") must issue an `integrate` command before the integration can be sponsored.\");\n            } else {\n                reply.println(\"The PR is not yet marked as ready to be sponsored. Please try again when it is.\");\n            }\n            return;\n        }\n\n        var acceptedHash = readyHash.get();\n        if (!pr.headHash().equals(acceptedHash)) {\n            if (!pr.labelNames().contains(\"auto\")) {\n                reply.print(\"The PR has been updated since the change author (@\" + pr.author().username() + \") \");\n                reply.println(\"issued the `integrate` command - the author must perform this command again.\");\n            } else {\n                reply.print(\"The PR is not yet marked as ready to be sponsored. Please try again when it is.\");\n            }\n            return;\n        }\n\n        var labels = new HashSet<>(pr.labelNames());\n        if (!labels.contains(\"ready\")) {\n            reply.println(\"This PR has not yet been marked as ready for integration.\");\n            return;\n        }\n\n        // Notify the author as well\n        reply.print(\"@\" + pr.author().username() + \" \");\n\n        // Execute merge\n        try (var integrationLock = IntegrationLock.create(pr, Duration.ofMinutes(10))) {\n            if (!integrationLock.isLocked()) {\n                log.severe(\"Unable to acquire the integration lock during sponsoring for \" + pr.webUrl());\n                reply.print(\"Unable to acquire the integration lock; aborting sponsored integration. The error has been logged and will be investigated.\");\n                return;\n            }\n\n            // Now that we have the integration lock, refresh the PR metadata\n            pr = pr.repository().pullRequest(pr.id());\n\n            var localRepo = IntegrateCommand.materializeLocalRepo(bot, pr, scratchArea);\n            var checkablePr = new CheckablePullRequest(pr, localRepo, bot.useStaleReviews(),\n                    bot.confOverrideRepository().orElse(null),\n                    bot.confOverrideName(),\n                    bot.confOverrideRef(),\n                    allComments,\n                    bot.reviewMerge(),\n                    new ReviewCoverage(bot.useStaleReviews(), bot.acceptSimpleMerges(), localRepo, pr));\n\n            // Validate the target hash if requested\n            if (!command.args().isBlank()) {\n                var wantedHash = new Hash(command.args());\n                if (!checkablePr.targetHash().equals(wantedHash)) {\n                    reply.print(\"The head of the target branch is no longer at the requested hash \" + wantedHash);\n                    reply.println(\" - it has moved to \" + checkablePr.targetHash() + \". Aborting integration.\");\n                    return;\n                }\n            }\n\n            // Now rebase onto the target hash\n            var rebaseMessage = new StringWriter();\n            var rebaseWriter = new PrintWriter(rebaseMessage);\n            var rebasedHash = checkablePr.mergeTarget(rebaseWriter);\n            if (rebasedHash.isEmpty()) {\n                reply.println(rebaseMessage);\n                return;\n            }\n\n            var original = checkablePr.findOriginalBackportHash();\n            var localHash = checkablePr.commit(rebasedHash.get(), censusInstance.namespace(), censusInstance.configuration().census().domain(),\n                    command.user().id(), original);\n\n            if (IntegrateCommand.runJcheck(pr, censusInstance, allComments, reply, checkablePr, localHash)) {\n                return;\n            }\n\n            if (!localHash.equals(checkablePr.targetHash())) {\n                var amendedHash = checkablePr.amendManualReviewersAndStaleReviewers(localHash, censusInstance.namespace(), original);\n                IntegrateCommand.addPrePushComment(pr, amendedHash, rebaseMessage.toString());\n                localRepo.push(amendedHash, pr.repository().authenticatedUrl(), pr.targetRef());\n                markIntegratedAndClosed(pr, amendedHash, reply, allComments);\n            } else {\n                reply.print(\"Warning! This commit did not result in any changes! \");\n                reply.println(\"No push attempt will be made.\");\n            }\n        } catch (IOException | CommitFailure e) {\n            log.log(Level.SEVERE, \"An error occurred during sponsored integration (\" + pr.webUrl() + \"): \" + e.getMessage(), e);\n            reply.println(\"An unexpected error occurred during sponsored integration. No push attempt will be made. \" +\n                                  \"The error has been logged and will be investigated. It is possible that this error \" +\n                                  \"is caused by a transient issue; feel free to retry the operation.\");\n        }\n    }\n\n    private void markIntegratedAndClosed(PullRequest pr, Hash amendedHash, PrintWriter reply, List<Comment> allComments) {\n        IntegrateCommand.markIntegratedAndClosed(pr, amendedHash, reply, allComments);\n    }\n\n    @Override\n    public String description() {\n        return \"performs integration of a PR that is authored by a non-committer\";\n    }\n\n    @Override\n    public String name() {\n        return sponsor.name();\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/Summary.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.nio.charset.StandardCharsets;\nimport java.util.*;\nimport java.util.regex.*;\nimport java.util.stream.Collectors;\n\npublic class Summary {\n    private static final String SUMMARY_MARKER = \"<!-- summary: '%s' -->\";\n    private static final Pattern MARKER_PATTERN = Pattern.compile(\"<!-- summary: '(.*?)' -->\");\n\n    static String setSummaryMarker(String summary) {\n        var encodedSummary = Base64.getEncoder().encodeToString(summary.getBytes(StandardCharsets.UTF_8));\n        return String.format(SUMMARY_MARKER, encodedSummary);\n    }\n\n    static Optional<String> summary(HostUser botUser, List<Comment> comments) {\n        var summaryActions = comments.stream()\n                                         .filter(comment -> comment.author().equals(botUser))\n                                         .map(comment -> MARKER_PATTERN.matcher(comment.body()))\n                                         .filter(Matcher::find)\n                                         .collect(Collectors.toList());\n        String summary = null;\n        for (var action : summaryActions) {\n            var decodedSummary = new String(Base64.getDecoder().decode(action.group(1)), StandardCharsets.UTF_8);\n            if (decodedSummary.isBlank()) {\n                summary = null;\n            } else {\n                summary = decodedSummary;\n            }\n        }\n\n        return Optional.ofNullable(summary);\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/SummaryCommand.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.io.PrintWriter;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.summary;\n\npublic class SummaryCommand implements CommandHandler {\n    private static final Pattern INVALID_SUMMARY_PATTERN = Pattern.compile(\"(^(Co-authored-by:)(.*))|(^(Reviewed-by:)(.*))|(^(Backport-of:)(.*))|(^[0-9]+:(.*))\");\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        if (!command.user().equals(pr.author())) {\n            reply.println(\"Only the author (@\" + pr.author().username() + \") is allowed to issue the `/summary` command.\");\n            return;\n        }\n\n        var currentSummary = Summary.summary(pr.repository().forge().currentUser(), allComments);\n        if (command.args().isBlank()) {\n            if (currentSummary.isPresent()) {\n                reply.println(\"Removing existing summary\");\n                reply.println(Summary.setSummaryMarker(\"\"));\n            } else {\n                reply.println(\"To set a summary, use the syntax `/summary <summary text>`\");\n            }\n        } else {\n            var summary = command.args().lines()\n                                 .map(String::strip)\n                                 .collect(Collectors.joining(\"\\n\"));\n            if (!checkSummary(summary)) {\n                reply.println(\"Invalid summary:\\n\" +\n                        \"\\n\" +\n                        \"```\\n\" +\n                        summary +\n                        \"\\n```\\n\" +\n                        \"A summary line cannot start with any of the following: \" +\n                        \"`<issue-id>:`, `Co-authored-by:`, `Reviewed-by:`, `Backport-of:`. \" +\n                        \"See [JEP 357](https://openjdk.org/jeps/357) for details.\");\n            } else {\n                var action = currentSummary.isPresent() ? \"Updating existing\" : \"Setting\";\n                if (summary.contains(\"\\n\")) {\n                    reply.println(action + \" summary to:\\n\" +\n                            \"\\n\" +\n                            \"```\\n\" +\n                            summary +\n                            \"\\n```\");\n                } else {\n                    reply.println(action + \" summary to `\" + summary + \"`\");\n                }\n                reply.println(Summary.setSummaryMarker(summary));\n            }\n        }\n    }\n\n    @Override\n    public String description() {\n        return \"updates the summary in the commit message\";\n    }\n\n    @Override\n    public String name() {\n        return summary.name();\n    }\n\n    @Override\n    public boolean multiLine() {\n        return true;\n    }\n\n    @Override\n    public boolean allowedInBody() {\n        return true;\n    }\n\n    private boolean checkSummary(String summary) {\n        String[] lines = summary.split(\"\\n\");\n        for (String line : lines) {\n            if (INVALID_SUMMARY_PATTERN.matcher(line).matches()) {\n                return false;\n            }\n        }\n        return true;\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/TagCommand.java",
    "content": "/*\n * Copyright (c) 2021, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.HostedCommit;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.jcheck.JCheckConfiguration;\n\nimport java.io.PrintWriter;\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.tag;\n\npublic class TagCommand implements CommandHandler {\n    private void showHelp(PrintWriter reply) {\n        reply.println(\"Usage: `/tag <name>`\");\n    }\n\n    @Override\n    public String description() {\n        return \"create a tag\";\n    }\n\n    @Override\n    public String name() {\n        return tag.name();\n    }\n\n    @Override\n    public boolean allowedInCommit() {\n        return true;\n    }\n\n    @Override\n    public boolean allowedInPullRequest() {\n        return false;\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, HostedCommit commit, LimitedCensusInstance censusInstance,\n            ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        try {\n            if (!bot.integrators().contains(command.user().username())) {\n                reply.println(\"Only integrators for this repository are allowed to use the `/tag` command.\");\n                return;\n            }\n            if (censusInstance.contributor(command.user()).isEmpty()) {\n                printInvalidUserWarning(bot, reply);\n                return;\n            }\n\n            var args = command.args();\n            if (args.isBlank()) {\n                showHelp(reply);\n                return;\n            }\n\n            var parts = args.split(\" \");\n            if (parts.length > 1) {\n                showHelp(reply);\n                return;\n            }\n            var tagName = parts[0];\n\n            var localRepoDir = scratchArea.get(this)\n                    .resolve(bot.repo().name());\n            var localRepo = bot.hostedRepositoryPool()\n                               .orElseThrow(() -> new IllegalStateException(\"Missing repository pool for PR bot\"))\n                               .materialize(bot.repo(), localRepoDir);\n            localRepo.fetch(bot.repo().authenticatedUrl(), commit.hash().toString(), true).orElseThrow();\n\n            var existingTagNames = localRepo.tags().stream().map(Tag::name).collect(Collectors.toSet());\n            if (existingTagNames.contains(tagName)) {\n                var hash = localRepo.resolve(tagName).orElseThrow(() ->\n                        new IllegalStateException(\"Cannot resolve tag with name \" + tagName + \" in repo \" + bot.repo().name()));\n                var hashUrl = bot.repo().webUrl(hash);\n                reply.println(\"A tag with name `\" + tagName + \"` already exists that refers to commit [\" + hash.abbreviate() + \"](\" + hashUrl + \").\");\n                return;\n            }\n\n            var jcheckConf = JCheckConfiguration.from(localRepo, commit.hash());\n            var tagPattern = jcheckConf.isPresent() ? jcheckConf.get().repository().tags() : null;\n            if (tagPattern != null && !tagName.matches(tagPattern)) {\n                reply.println(\"The given tag name `\" + tagName + \"` is not of the form `\" + tagPattern + \"`.\");\n                return;\n            }\n\n            var domain = censusInstance.configuration().census().domain();\n            var contributor = censusInstance.contributor(command.user()).orElseThrow();\n            var email = contributor.username() + \"@\" + domain;\n            var message = \"Added tag \" + tagName + \" for changeset \" + commit.hash().abbreviate();\n            var name = contributor.fullName().isPresent() ? contributor.fullName().get() : contributor.username();\n            var tag = localRepo.tag(commit.hash(), tagName, message, name, email);\n            localRepo.push(tag, bot.repo().authenticatedUrl(), false);\n            reply.println(\"The tag [\" + tag.name() + \"](\" + bot.repo().webUrl(tag) + \") was successfully created.\");\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/TemplateCommand.java",
    "content": "/*\n * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.vcs.Branch;\nimport org.openjdk.skara.vcs.VCS;\n\nimport java.io.PrintWriter;\nimport java.net.URI;\nimport java.util.List;\nimport java.util.Optional;\n\nimport static org.openjdk.skara.bots.common.PullRequestConstants.PROGRESS_MARKER;\nimport static org.openjdk.skara.bots.common.CommandNameEnum.template;\n\npublic class TemplateCommand implements CommandHandler {\n    private static final URI PR_TEMPLATE_DOC =\n        URI.create(\"https://docs.github.com/en/communities/\" +\n                   \"using-templates-to-encourage-useful-issues-and-pull-requests/\" +\n                   \"about-issue-and-pull-request-templates#pull-request-templates\");\n\n    private static final String GITHUB_PR_TEMPLATE_PATH = \".github/pull_request_template.md\";\n    private static final String GITLAB_PR_TEMPLATE_PATH = \".gitlab/merge_request_templates/default.md\";\n\n    private static final String GIT_DEFAULT_BRANCH = Branch.defaultFor(VCS.GIT).name();\n\n\n    @Override\n    public String description() {\n        return \"Appends a [pull request template](\" + PR_TEMPLATE_DOC + \") to the pull request body.\";\n    }\n\n    @Override\n    public String name() {\n        return template.name();\n    }\n\n    @Override\n    public boolean allowedInBody() {\n        return true;\n    }\n\n    private static Optional<String> getPullRequestTemplate(HostedRepository repo) {\n        // Only load templates from the \"master\" branch (not from the PR target branch)\n\n        var template = repo.fileContents(GITHUB_PR_TEMPLATE_PATH, GIT_DEFAULT_BRANCH);\n        if (template.isPresent()) {\n            return template;\n        }\n\n        template = repo.fileContents(GITLAB_PR_TEMPLATE_PATH, GIT_DEFAULT_BRANCH);\n        if (template.isPresent()) {\n            return template;\n        }\n\n        return repo.forge().defaultPullRequestTemplate();\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply)\n    {\n        if (!command.user().equals(pr.author())) {\n            reply.println(\"Only the pull request author can append a pull request template\");\n            return;\n        }\n\n        if (command.args().isEmpty()) {\n            reply.println(\"Missing command 'append', usage: `/template append`\");\n            return;\n        }\n        if (!command.args().equals(\"append\")) {\n            reply.println(\"Unknown argument '\" + command.args() + \"', usage: `/template append`\");\n            return;\n        }\n\n        var repo = pr.repository();\n        var template = getPullRequestTemplate(repo);\n        if (template.isEmpty()) {\n            reply.println(\"This repository does not have a pull request template\");\n            return;\n        }\n\n        // Retrieve the body again here to lower the chance of concurrent updates\n        var body = repo.pullRequest(pr.id()).body();\n        var markerIndex = body.lastIndexOf(PROGRESS_MARKER);\n        var userBody = markerIndex == -1 ? body : body.substring(0, markerIndex).stripTrailing();\n        var newBody = userBody + \"\\n\\n\" + template.get().trim();\n        if (markerIndex != -1) {\n            newBody += \"\\n\\n\" + body.substring(markerIndex);\n        }\n\n        pr.setBody(newBody);\n        reply.println(\"The pull request template has been appended to the pull request body\");\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/TouchCommand.java",
    "content": "/*\n * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.io.PrintWriter;\nimport java.util.List;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.touch;\nimport static org.openjdk.skara.bots.common.PullRequestConstants.TOUCH_COMMAND_RESPONSE_MARKER;\n\npublic class TouchCommand implements CommandHandler {\n    private void showHelp(PrintWriter reply) {\n        reply.println(\"Usage: `/touch` or `/keepalive`\");\n    }\n\n    @Override\n    public String description() {\n        return \"Re-evaluates the pull request and resets the inactivity timeout.\";\n    }\n\n    @Override\n    public String name() {\n        return touch.name();\n    }\n\n    @Override\n    public boolean allowedInBody() {\n        return false;\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance, ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        if (!pr.author().equals(command.user()) && censusInstance.contributor(command.user()).isEmpty()) {\n            printInvalidUserWarning(bot, reply);\n            return;\n        }\n\n        if (pr.isClosed()) {\n            reply.println(\"This command can only be used in open pull requests.\");\n            return;\n        }\n\n        reply.println(\"The pull request is being re-evaluated and the inactivity timeout has been reset.\" + TOUCH_COMMAND_RESPONSE_MARKER);\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/TrailerCommand.java",
    "content": "/*\n * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport java.io.PrintWriter;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.regex.Pattern;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport static org.openjdk.skara.bots.common.CommandNameEnum.trailer;\n\npublic class TrailerCommand implements CommandHandler {\n    public enum TrailerType {\n        SINGLE,\n        LIST;\n\n        static TrailerType fromString(String value) {\n            return switch (value) {\n                case \"single\" -> SINGLE;\n                case \"list\" -> LIST;\n                default -> throw new IllegalArgumentException(\"Unknown trailer type: \" + value\n                        + \", expected one of: single, list\");\n            };\n        }\n    }\n\n    public record TrailerConfig(String key, String alias, String description, TrailerType type, List<Pattern> values) {}\n\n    private static final Pattern COMMAND_PATTERN = Pattern.compile(\n            \"^(?:(set)\\\\s+([\\\\p{Alnum}-]+) (.+)?|(remove)\\\\s+([\\\\p{Alnum}-]+))$\");\n\n    @Override\n    public String description() {\n        return \"Set or remove a custom commit message trailer\";\n    }\n\n    @Override\n    public String name() {\n        return trailer.name();\n    }\n\n    @Override\n    public boolean allowedInBody() {\n        return true;\n    }\n\n    private void showHelp(PullRequestBot bot, PrintWriter reply) {\n        reply.println(\"Syntax: `/trailer (set|remove) (<key>|<alias>) [<value>]`. For example:\");\n        reply.println();\n        reply.println(\" * `/trailer set My-Custom-Trailer Some custom trailer value`\");\n        reply.println(\" * `/trailer remove My-Custom-Trailer`\");\n        reply.println();\n        reply.println(\"Only trailer keys that have been configured for the repository are allowed.\");\n        if (bot.trailerConfigs().isEmpty()) {\n            reply.println(\"No custom trailers configured for this repository.\");\n        } else {\n            printConfiguredTrailers(bot, reply);\n        }\n    }\n\n    private static void printConfiguredTrailers(PullRequestBot bot, PrintWriter reply) {\n        reply.println(\"For this repository, the following custom trailers have been configured:\");\n        for (TrailerConfig trailerConfig : bot.trailerConfigs()) {\n            reply.println();\n            reply.println(\"- Key: \" + trailerConfig.key);\n            if (trailerConfig.alias != null) {\n                reply.println(\"- Alias: \" + trailerConfig.alias);\n            }\n            reply.println(\"- Description: \" + trailerConfig.description);\n            reply.println(\"- Type: \" + trailerConfig.type);\n            reply.println(\"- Valid value pattern(s):\");\n            for (Pattern pattern : trailerConfig.values()) {\n                reply.println(\"  - `\" + pattern.pattern() + \"`\");\n            }\n        }\n    }\n\n    private static boolean matchesAnyPattern(String value, List<Pattern> patterns) {\n        return patterns.stream().anyMatch(p -> p.matcher(value).matches());\n    }\n\n    private static boolean isValidValue(TrailerConfig config, String value) {\n        return switch (config.type()) {\n            case SINGLE -> matchesAnyPattern(value, config.values());\n            case LIST -> Arrays.stream(value.split(\",\", -1))\n                    .map(String::trim)\n                    .allMatch(item -> !item.isEmpty() && matchesAnyPattern(item, config.values()));\n        };\n    }\n\n    @Override\n    public void handle(PullRequestBot bot, PullRequest pr, CensusInstance censusInstance,\n            ScratchArea scratchArea, CommandInvocation command, List<Comment> allComments, PrintWriter reply) {\n        if (!command.user().equals(pr.author())) {\n            reply.println(\"Only the author (@\" + pr.author().username() + \") is allowed to issue the `trailer` command.\");\n            return;\n        }\n\n        var matcher = COMMAND_PATTERN.matcher(command.args());\n        if (!matcher.matches()) {\n            showHelp(bot, reply);\n            return;\n        }\n\n        if (\"set\".equals(matcher.group(1))) {\n            var key = matcher.group(2);\n            var config = findTrailerConfig(key, bot, reply);\n            if (config.isPresent()) {\n                var configKey = config.get().key();\n                var value = matcher.group(3);\n                if (isValidValue(config.get(), value)) {\n                    reply.println(Trailers.setTrailerMarker(configKey, value));\n                    reply.println(\"Trailer `\" + configKey + \"` with value `\" + value + \"` successfully set.\");\n                } else {\n                    if (config.get().type() == TrailerType.LIST) {\n                        reply.println(\"Trailer value `\" + value + \"` for trailer `\" + configKey\n                                + \"` does not match any valid value pattern. Each list item must match one of:\");\n                    } else {\n                        reply.println(\"Trailer value `\" + value + \"` for trailer `\" + configKey\n                                + \"` does not match any valid value pattern:\");\n                    }\n                    for (Pattern pattern : config.get().values()) {\n                        reply.println(\"- `\" + pattern.pattern() + \"`\");\n                    }\n                }\n            }\n        } else if (\"remove\".equals(matcher.group(4))) {\n            var key = matcher.group(5);\n            var config = findTrailerConfig(key, bot, reply);\n            if (config.isPresent()) {\n                var configKey = config.get().key();\n                var existing = Trailers.trailers(pr.repository().forge().currentUser(), allComments);\n                if (existing.stream().anyMatch(trailer -> trailer.key().equals(configKey))) {\n                    reply.println(Trailers.removeTrailerMarker(configKey));\n                    reply.println(\"Trailer `\" + configKey + \"` successfully removed.\");\n                } else {\n                    if (existing.isEmpty()) {\n                        reply.println(\"There are no custom trailers set for this pull request.\");\n                    } else {\n                        reply.println(\"Trailer `\" + configKey + \"` was not found.\");\n                        reply.println(\"Current custom trailers for this pull request are:\");\n                        for (var trailer : existing) {\n                            reply.println(\"- \" + trailer.key() + \": \" + trailer.value());\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    private Optional<TrailerConfig> findTrailerConfig(String key, PullRequestBot bot, PrintWriter reply) {\n        var trailerConfigs = bot.trailerConfigs();\n        if (trailerConfigs.isEmpty()) {\n            reply.println(\"There are no custom trailers configured for this repository.\");\n            return Optional.empty();\n        } else {\n            var config = trailerConfigs.stream()\n                    .filter(c -> key.equals(c.key()) || key.equals(c.alias()))\n                    .findFirst();\n            if (config.isEmpty()) {\n                reply.println(\"Trailer `\" + key + \"` is not configured for this repository.\");\n                printConfiguredTrailers(bot, reply);\n            }\n            return config;\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/main/java/org/openjdk/skara/bots/pr/Trailers.java",
    "content": "/*\n * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nclass Trailers {\n    private static final String SET_MARKER = \"<!-- set trailer: '%s' '%s' -->\";\n    private static final String REMOVE_MARKER = \"<!-- remove trailer: '%s' -->\";\n\n    private static final Pattern MARKER_PATTERN = Pattern.compile(\n            \"<!-- (?:(set) trailer: '([\\\\p{Alnum}-]+?)' '(.*?)'|(remove) trailer: '([\\\\p{Alnum}-]+?)') -->\");\n\n    static String setTrailerMarker(String key, String value) {\n        return String.format(SET_MARKER, key, value);\n    }\n\n    static String removeTrailerMarker(String key) {\n        return String.format(REMOVE_MARKER, key);\n    }\n\n    static List<CommitMessage.CustomTrailer> trailers(HostUser botUser, List<Comment> comments) {\n        var trailerActions = comments.stream()\n                .filter(comment -> comment.author().equals(botUser))\n                .map(comment -> MARKER_PATTERN.matcher(comment.body()))\n                .filter(Matcher::find)\n                .toList();\n        var trailers = new LinkedHashMap<String, CommitMessage.CustomTrailer>();\n        for (Matcher action : trailerActions) {\n            if (\"set\".equals(action.group(1))) {\n                trailers.put(action.group(2), new CommitMessage.CustomTrailer(action.group(2), action.group(3)));\n            } else if (\"remove\".equals(action.group(4))) {\n                trailers.remove(action.group(5));\n            }\n        }\n        return List.copyOf(trailers.sequencedValues());\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/AdditionalConfigurationTests.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.jcheck.JCheck;\n\nimport java.io.*;\nimport java.util.*;\n\nimport org.junit.jupiter.api.*;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass AdditionalConfigurationTests {\n    @Test\n    void minimumShouldBeDisabled(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(bot).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            var reviewerPr = (TestPullRequest)integrator.pullRequest(pr.id());\n\n            // Require two reviewers\n            reviewerPr.addComment(\"/reviewers 2\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            var jcheckConf = JCheck.parseConfiguration(localRepo, masterHash, List.of());\n            assertTrue(jcheckConf.isPresent());\n            var additional = AdditionalConfiguration.get(jcheckConf.get(), bot.forge().currentUser(),\n                                                         pr.comments(), MergePullRequestReviewConfiguration.ALWAYS);\n            var expected = List.of(\n                \"[checks \\\"reviewers\\\"]\",\n                \"lead=0\",\n                \"reviewers=1\",\n                \"committers=0\",\n                \"authors=1\",\n                \"contributors=0\",\n                \"minimum=disable\",\n                \"merge=check\"\n            );\n            assertEquals(expected, additional);\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/ApprovalAndApproveCommandTests.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\nimport org.openjdk.skara.forge.Review;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.test.CheckableRepository;\nimport org.openjdk.skara.test.HostCredentials;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestBotRunner;\n\nimport java.io.IOException;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.regex.Pattern;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.common.PullRequestConstants.APPROVAL_LABEL;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\npublic class ApprovalAndApproveCommandTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue.setProperty(\"priority\", JSON.of(\"4\"));\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            Approval approval = new Approval(\"\", \"-critical-request\", \"-critical-approved\",\n                    \"-critical-rejected\", \"https://example.com\", true, \"maintainer approval\");\n            approval.addBranchPrefix(Pattern.compile(\"jdk20.0.1\"), \"CPU23_04\");\n            approval.addBranchPrefix(Pattern.compile(\"jdk20.0.2\"), \"CPU23_05\");\n\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(issuePRMap)\n                    .approval(approval)\n                    .integrators(Set.of(reviewer.forge().currentUser().username()))\n                    .build();\n            var issueBot = new IssueBot(issueProject, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"jdk20.0.1\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n\n            var pr = credentials.createPullRequest(author, \"jdk20.0.1\", \"edit\", issue.id() + \": This is an issue\");\n\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            pr.addComment(\"/approval\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"usage: `/approval [<id>] (request|cancel) [<text>]`\");\n            assertFalse(pr.store().labelNames().contains(APPROVAL_LABEL));\n\n            pr.addComment(\"/approval JDK-1 request\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Approval can only be requested for issues in the TEST project.\");\n            assertFalse(pr.store().labelNames().contains(APPROVAL_LABEL));\n\n            pr.addComment(\"/approval request My reason line1\\nMy reason line2\\nMy reason line3\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"The approval [request](http://localhost/project/testTEST-1?focusedCommentId=0) has been created successfully.\");\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertTrue(pr.store().labelNames().contains(APPROVAL_LABEL));\n\n            pr.addComment(\"/approval cancel\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"The approval request has been cancelled successfully.\");\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertFalse(pr.store().labelNames().contains(APPROVAL_LABEL));\n\n            pr.addComment(\"/approval 1 request\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"The approval [request](http://localhost/project/testTEST-1?focusedCommentId=0) has been created successfully.\");\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertTrue(pr.store().labelNames().contains(APPROVAL_LABEL));\n\n            pr.addComment(\"/approval 1 request new reason line1\\nnew reason line2\\nnew reason line3\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"The approval [request](http://localhost/project/testTEST-1?focusedCommentId=0) has been updated successfully.\");\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertTrue(issue.comments().stream().anyMatch(comment -> comment.body().contains(\"new reason\")));\n\n            pr.addComment(\"/approval 1 request new reason line1\\nnew reason line2\\nnew reason line3\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"The approval [request](http://localhost/project/testTEST-1?focusedCommentId=0) was already up to date.\");\n\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"LGTM\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            reviewerPr.addComment(\"/approve yes\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"1: The approval request has been approved.\");\n\n            reviewerPr.addComment(\"/approve 1 yes\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"1: The approval request has been approved.\");\n            assertTrue(pr.store().labelNames().contains(APPROVAL_LABEL));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertFalse(pr.store().labelNames().contains(APPROVAL_LABEL));\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n\n            pr.addComment(\"/approval cancel cancel it\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"The request has already been handled by a maintainer and can no longer be canceled.\");\n        }\n    }\n\n    @Test\n    void multipleIssues(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var issue1 = issueProject.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue1.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue1.setProperty(\"priority\", JSON.of(\"4\"));\n            issue1.addLabel(\"CPU23_04-critical-request\");\n            var issue2 = issueProject.createIssue(\"This is an issue 2\", List.of(), Map.of());\n            issue2.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue2.setProperty(\"priority\", JSON.of(\"2\"));\n            issue2.addLabel(\"CPU23_04-critical-request\");\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            Approval approval = new Approval(\"\", \"-critical-request\", \"-critical-approved\",\n                    \"-critical-rejected\", \"https://example.com\", true, \"maintainer approval\");\n            approval.addBranchPrefix(Pattern.compile(\"jdk20.0.1\"), \"CPU23_04\");\n            approval.addBranchPrefix(Pattern.compile(\"jdk20.0.2\"), \"CPU23_05\");\n\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(issuePRMap)\n                    .approval(approval)\n                    .integrators(Set.of(reviewer.forge().currentUser().username()))\n                    .build();\n            var issueBot = new IssueBot(issueProject, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"jdk20.0.1\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n\n            var pr = credentials.createPullRequest(author, \"jdk20.0.1\", \"edit\", issue1.id() + \": This is an issue\");\n\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            pr.addComment(\"/issue \" + issue2.id());\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Adding additional issue to issue list: `2: This is an issue 2`.\");\n\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"LGTM\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            pr.addComment(\"/approval 1 cancel\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertLastCommentContains(pr, \"This change is now ready for you to apply for [maintainer approval](https://example.com).\");\n            pr.addComment(\"/approval 2 cancel\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(issueBot);\n\n            reviewerPr.addComment(\"/approve yes\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"1: There is no maintainer approval request for this issue.\");\n            assertLastCommentContains(pr, \"2: There is no maintainer approval request for this issue.\");\n\n            reviewerPr.addComment(\"/approve JDK-1 yes\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"JDK-1: Can only approve issues in the TEST project.\");\n\n            pr.addComment(\"/approval request my reason\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertLastCommentContains(pr, \"1: The approval [request](http://localhost/project/testTEST-1?focusedCommentId=0) has been created successfully.\");\n            assertLastCommentContains(pr, \"2: The approval [request](http://localhost/project/testTEST-2?focusedCommentId=0) has been created successfully.\");\n\n            pr.addComment(\"/approval cancel\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertLastCommentContains(pr, \"1: The approval request has been cancelled successfully.\");\n            assertLastCommentContains(pr, \"2: The approval request has been cancelled successfully.\");\n\n            pr.addComment(\"/approval 1 request my reason for 1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertLastCommentContains(pr, \"1: The approval [request](http://localhost/project/testTEST-1?focusedCommentId=0) has been created successfully.\");\n\n            pr.addComment(\"/approval request my reason\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertLastCommentContains(pr, \"1: The approval [request](http://localhost/project/testTEST-1?focusedCommentId=0) has been updated successfully.\");\n            assertLastCommentContains(pr, \"2: The approval [request](http://localhost/project/testTEST-2?focusedCommentId=0) has been created successfully.\");\n\n            reviewerPr.addComment(\"/approve 1 no\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertLastCommentContains(pr, \"1: The approval request has been rejected.\");\n\n            reviewerPr.addComment(\"/approve no\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertLastCommentContains(pr, \"1: The approval request has been rejected.\");\n            assertLastCommentContains(pr, \"2: The approval request has been rejected.\");\n\n            reviewerPr.addComment(\"/approve 1 yes\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertLastCommentContains(pr, \"1: The approval request has been approved.\");\n\n            reviewerPr.addComment(\"/approve yes\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"1: The approval request has been approved.\");\n            assertLastCommentContains(pr, \"2: The approval request has been approved.\");\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/ApprovalTests.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.util.regex.Pattern;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class ApprovalTests {\n    @Test\n    void simple() {\n        Approval approval = new Approval(\"\", \"jdk17u-fix-request\", \"jdk17u-fix-yes\",\n                \"jdk17u-fix-no\", \"https://example.com\", true, \"maintainer approval\");\n        assertEquals(\"jdk17u-fix-request\", approval.requestedLabel(\"master\"));\n        assertEquals(\"jdk17u-fix-yes\", approval.approvedLabel(\"master\"));\n        assertEquals(\"jdk17u-fix-no\", approval.rejectedLabel(\"master\"));\n        assertEquals(\"https://example.com\", approval.documentLink());\n        assertTrue(approval.approvalComment());\n        assertEquals(\"maintainer approval\", approval.approvalTerm());\n        assertTrue(approval.needsApproval(\"master\"));\n\n        approval = new Approval(\"jdk17u-fix-\", \"request\", \"yes\", \"no\",\n                \"https://example.com\", false, \"maintainer approval\");\n        assertEquals(\"jdk17u-fix-request\", approval.requestedLabel(\"master\"));\n        assertEquals(\"jdk17u-fix-yes\", approval.approvedLabel(\"master\"));\n        assertEquals(\"jdk17u-fix-no\", approval.rejectedLabel(\"master\"));\n        assertFalse(approval.approvalComment());\n        assertEquals(\"maintainer approval\", approval.approvalTerm());\n        assertTrue(approval.needsApproval(\"master\"));\n\n        approval = new Approval(\"\", \"-critical-request\", \"-critical-approved\",\n                \"-critical-rejected\", \"https://example.com\", false, \"critical request\");\n        approval.addBranchPrefix(Pattern.compile(\"jdk20.0.1\"), \"CPU23_04\");\n        approval.addBranchPrefix(Pattern.compile(\"jdk20.0.2\"), \"CPU23_05\");\n        assertEquals(\"CPU23_04-critical-request\", approval.requestedLabel(\"jdk20.0.1\"));\n        assertEquals(\"CPU23_04-critical-approved\", approval.approvedLabel(\"jdk20.0.1\"));\n        assertEquals(\"CPU23_04-critical-rejected\", approval.rejectedLabel(\"jdk20.0.1\"));\n        assertEquals(\"CPU23_05-critical-request\", approval.requestedLabel(\"jdk20.0.2\"));\n        assertEquals(\"CPU23_05-critical-approved\", approval.approvedLabel(\"jdk20.0.2\"));\n        assertEquals(\"CPU23_05-critical-rejected\", approval.rejectedLabel(\"jdk20.0.2\"));\n        assertFalse(approval.needsApproval(\"master\"));\n        assertTrue(approval.needsApproval(\"jdk20.0.1\"));\n        assertTrue(approval.needsApproval(\"jdk20.0.2\"));\n        assertFalse(approval.needsApproval(\"jdk20.0.3\"));\n        assertFalse(approval.approvalComment());\n        assertEquals(\"critical request\", approval.approvalTerm());\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/AuthorCommandTests.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\nimport org.openjdk.skara.forge.Review;\nimport org.openjdk.skara.test.CheckableRepository;\nimport org.openjdk.skara.test.HostCredentials;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestBotRunner;\nimport org.openjdk.skara.vcs.Repository;\n\nimport java.io.IOException;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\npublic class AuthorCommandTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue an invalid command\n            pr.addComment(\"/author xx\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr, \"Syntax\");\n\n            // set an author\n            pr.addComment(\"/author set Test Person <test@test.test>\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr, \"Setting overriding author to `Test Person <test@test.test>`. \" +\n                    \"When this pull request is integrated, the overriding author will be used in the commit.\");\n\n            // Remove it\n            pr.addComment(\"/author remove Test Person <test@test.test>\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr, \"Overriding author `Test Person <test@test.test>` was successfully removed. \" +\n                    \"When this pull request is integrated, the pull request author will be used as the author of the commit.\");\n\n            // Remove something that isn't there\n            pr.addComment(\"/author remove Test Person 2 <test2@test.test>\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr, \"There is no overriding author set for this pull request.\");\n\n            // Remove something that isn't there\n            pr.addComment(\"/author remove\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr, \"There is no overriding author set for this pull request.\");\n\n            // Now add someone back again\n            pr.addComment(\"/author Test Person <test@test.test>\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr, \"Setting overriding author to `Test Person <test@test.test>`. \" +\n                    \"When this pull request is integrated, the overriding author will be used in the commit.\");\n\n            // Remove something that isn't there\n            pr.addComment(\"/author remove Test Person 2 <test2@test.test>\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr, \"Cannot remove `Test Person 2 <test2@test.test>`, the overriding author is currently set to: `Test Person <test@test.test>`\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            var pushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"change now passes all *automated*\"))\n                    .count();\n            assertEquals(1, pushed);\n\n            // Integrate\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an ok message\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            // The change should now be present on the master branch\n            var pushedFolder = tempFolder.path().resolve(\"pushed\");\n            var pushedRepo = Repository.materialize(pushedFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n\n            assertEquals(\"Test Person\", headCommit.author().name());\n            assertEquals(\"Generated Committer 2\", headCommit.committer().name());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/BackportCommitCommandTests.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.util.*;\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class BackportCommitCommandTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var forkCredentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var fork = forkCredentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), fork))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Initiate the fork repo\n            localRepo.push(masterHash, fork.authenticatedUrl(), \"master\", true);\n\n            // Make a change in another branch\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\");\n            // Create more branches\n            for (int i = 1; i <= 20; i++) {\n                localRepo.push(editHash, author.authenticatedUrl(), \"jdk\" + i, true);\n            }\n            // Add a backport command\n            author.addCommitComment(editHash, \"/backport \" + author.name());\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"backport\"));\n            assertTrue(botReply.body().contains(\"was successfully created\"));\n            assertTrue(botReply.body().contains(\"To create a pull request\"));\n            assertTrue(botReply.body().contains(\"with this backport\"));\n            assertTrue(botReply.body().contains(\"@\" + botReply.author().username()));\n            assertEquals(botReply.body().indexOf(\"@\" + botReply.author().username()), botReply.body().lastIndexOf(\"@\" + botReply.author().username()));\n\n            // Add a backport command\n            author.addCommitComment(editHash, \"/backport \" + author.name() + \":\" + author.defaultBranchName());\n            TestBotRunner.runPeriodicItems(bot);\n            recentCommitComments = author.recentCommitComments();\n            assertEquals(4, recentCommitComments.size());\n            botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"To create a pull request with this backport targeting \" +\n                    \"[\" + author.name() + \":\" + author.defaultBranchName() + \"]\"));\n\n            // Add a backport command\n            author.addCommitComment(editHash, \"/backport :\" + author.defaultBranchName());\n            TestBotRunner.runPeriodicItems(bot);\n            recentCommitComments = author.recentCommitComments();\n            assertEquals(6, recentCommitComments.size());\n            botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"To create a pull request with this backport targeting \" +\n                    \"[\" + author.name() + \":\" + author.defaultBranchName() + \"]\"));\n\n            author.addCommitComment(editHash, \"/backport jdk11\");\n            TestBotRunner.runPeriodicItems(bot);\n            recentCommitComments = author.recentCommitComments();\n            assertEquals(8, recentCommitComments.size());\n            botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"There is a branch `jdk11` in the current repository `test`.\"));\n\n            author.addCommitComment(editHash, \"/backport :jdk31\");\n            TestBotRunner.runPeriodicItems(bot);\n            recentCommitComments = author.recentCommitComments();\n            assertEquals(10, recentCommitComments.size());\n            botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"List of valid branches:\"));\n        }\n    }\n\n    @Test\n    void unknownTargetRepo(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author, \"foobar/other-repo\", author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change in another branch\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\");\n\n            // Add a backport command\n            author.addCommitComment(editHash, \"/backport foobar/non-existing-repo\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"target repository\"));\n            assertTrue(botReply.body().contains(\"is not a valid target for backports\"));\n            assertTrue(botReply.body().contains(\"List of valid target repositories: `foobar/other-repo`, `test`\"));\n            assertEquals(List.of(), author.openPullRequests());\n        }\n    }\n\n    @Test\n    void unknownTargetRepoFork(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .forks(Map.of(\"foobar/other-repo\", author))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change in another branch\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\");\n\n            // Add a backport command\n            author.addCommitComment(editHash, \"/backport \" + author.name());\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"is not a valid target for backports\"));\n            assertTrue(botReply.body().contains(\"List of valid target repositories: `foobar/other-repo`\"));\n            assertEquals(List.of(), author.openPullRequests());\n        }\n    }\n\n    @Test\n    void unknownTargetBranch(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change in another branch\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\");\n\n            // Add a backport command\n            author.addCommitComment(editHash, \"/backport \" + author.name() + \" non-existing-branch\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"target branch\"));\n            assertTrue(botReply.body().contains(\"does not exist\"));\n            assertEquals(List.of(), author.openPullRequests());\n        }\n    }\n\n    @Test\n    void backportDoesNotApply(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var forkCredentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var fork = forkCredentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), fork))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var initialHash = localRepo.head();\n\n            // Initiate the fork repository\n            localRepo.push(initialHash, fork.authenticatedUrl(), \"master\", true);\n\n            // Make a change and push it to edit branch\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n\n            // Add another conflicting change in the master branch\n            localRepo.checkout(initialHash);\n            var masterHash2 = CheckableRepository.appendAndCommit(localRepo, \"a different line\");\n            localRepo.push(masterHash2, author.authenticatedUrl(), \"master\", true);\n\n            // Add a backport command\n            author.addCommitComment(editHash, \"/backport \" + author.name() + \" master\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"Could **not** automatically backport\"));\n            assertTrue(botReply.body().contains(\"Please fetch the appropriate branch/commit and manually resolve these conflicts\"));\n            assertTrue(botReply.body().contains(\"master:master\"));\n            assertTrue(botReply.body().contains(\"$ git checkout master\"));\n            assertTrue(botReply.body().contains(\"Below you can find a suggestion for the pull request body:\"));\n            assertEquals(List.of(), author.openPullRequests());\n        }\n    }\n\n    @Test\n    void backportTwice(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change in another branch\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\");\n\n            // Add a backport command\n            author.addCommitComment(editHash, \"/backport \" + author.name());\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"backport\"));\n            assertTrue(botReply.body().contains(\"was successfully created\"));\n            assertTrue(botReply.body().contains(\"To create a pull request\"));\n            assertTrue(botReply.body().contains(\"with this backport\"));\n\n            // Add a backport command again\n            author.addCommitComment(editHash, \"/backport \" + author.name());\n            TestBotRunner.runPeriodicItems(bot);\n\n            recentCommitComments = author.recentCommitComments();\n            assertEquals(4, recentCommitComments.size());\n            botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"backport\"));\n            assertTrue(botReply.body().contains(\"was successfully created\"));\n            assertTrue(botReply.body().contains(\"To create a pull request\"));\n            assertTrue(botReply.body().contains(\"with this backport\"));\n        }\n    }\n\n    /**\n     * Tests backport with unrelated change in target and a common dependency\n     * change in both target and source\n     */\n    @Test\n    void complex(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var forkCredentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var fork = forkCredentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .forks(Map.of(author.name(), fork))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Initiate the fork repo\n            localRepo.push(masterHash, fork.authenticatedUrl(), \"master\", true);\n\n            // Make an unrelated change in master\n            var unrelated = localRepo.root().resolve(\"unrelated.txt\");\n            Files.writeString(unrelated, \"Hello\");\n            localRepo.add(unrelated);\n            var unrelatedHash = localRepo.commit(\"Unrelated\", \"X\", \"x@y.z\");\n            localRepo.push(unrelatedHash, author.authenticatedUrl(), \"master\");\n\n            // Make a change in edit branch, without the unrelated change\n            localRepo.checkout(masterHash);\n            var editHashCommon = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHashCommon, author.authenticatedUrl(), \"edit\");\n\n            // Make the same change in master\n            localRepo.checkout(unrelatedHash);\n            var masterHashCommon = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHashCommon, author.authenticatedUrl(), \"master\");\n\n            // Make another change in edit branch that will be the source of the backport\n            localRepo.checkout(editHashCommon);\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\");\n\n            // Add a backport command\n            author.addCommitComment(editHash, \"/backport \" + author.name());\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"backport\"));\n            assertTrue(botReply.body().contains(\"was successfully created\"));\n            assertTrue(botReply.body().contains(\"To create a pull request\"));\n            assertTrue(botReply.body().contains(\"with this backport\"));\n        }\n    }\n\n    @Test\n    void alreadyPresent(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .forks(Map.of(author.name(), author))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change in another branch\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\");\n\n            // Make the same change in the master branch\n            localRepo.push(editHash, author.authenticatedUrl(), \"master\");\n\n            // Add a backport command\n            author.addCommitComment(editHash, \"/backport \" + author.name());\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"Could **not** apply backport\"));\n            assertTrue(botReply.body().contains(\"because the change is already present in the target.\"));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/BackportPRCommandTests.java",
    "content": "/*\n * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\nimport org.openjdk.skara.forge.Review;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.util.Map;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\npublic class BackportPRCommandTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var targetRepo = credentials.getHostedRepository(\"targetRepo\");\n            var targetRepo2 = credentials.getHostedRepository(\"targetRepo2\");\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .censusRepo(censusBuilder.build())\n                    .seedStorage(seedFolder)\n                    .forks(Map.of(\"targetRepo\", targetRepo, \"targetRepo2\", targetRepo2, \"test\", author))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            var reviewerPr = (TestPullRequest) integrator.pullRequest(pr.id());\n\n            // Enable backport for targetRepo on master\n            pr.addComment(\"/backport targetRepo\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Backport for repo `targetRepo` on branch `master` was successfully enabled\");\n            assertTrue(pr.store().labelNames().contains(\"backport=targetRepo:master\"));\n\n            // Enable backport for targetRepo2 on dev, but dev does not exist\n            pr.addComment(\"/backport targetRepo2 dev\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"The target branch `dev` does not exist\");\n            assertFalse(pr.store().labelNames().contains(\"backport=targetRepo2:dev\"));\n\n            // Enable backport for targetRepo2 on dev\n            localRepo.push(masterHash, targetRepo2.authenticatedUrl(), \"dev\", true);\n            pr.addComment(\"/backport targetRepo2 dev\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Backport for repo `targetRepo2` on branch `dev` was successfully enabled\");\n            assertTrue(pr.store().labelNames().contains(\"backport=targetRepo2:dev\"));\n\n            // Enable backport for test on master\n            pr.addComment(\"/backport :master\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Backport for repo `test` on branch `master` was successfully enabled\");\n            assertTrue(pr.store().labelNames().contains(\"backport=test:master\"));\n\n            // Disable backport for test on master\n            pr.addComment(\"/backport disable test:master\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Backport for repo `test` on branch `master` was successfully disabled\");\n            assertFalse(pr.store().labelNames().contains(\"backport=test:master\"));\n\n            // Disable backport for targetRepo on master\n            reviewerPr.addComment(\"/backport disable targetRepo\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Backport for repo `targetRepo` on branch `master` was successfully disabled.\");\n            assertFalse(pr.store().labelNames().contains(\"backport=targetRepo:master\"));\n\n            // Disable backport for targetRepo again\n            reviewerPr.addComment(\"/backport disable targetRepo\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Backport for repo `targetRepo` on branch `master` was already disabled.\");\n            assertFalse(pr.store().labelNames().contains(\"backport=targetRepo:master\"));\n\n            // Enable backport for targetRepo on master as reviewer\n            reviewerPr.addComment(\"/backport targetRepo\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Backport for repo `targetRepo` on branch `master` was successfully enabled\");\n            assertTrue(pr.store().labelNames().contains(\"backport=targetRepo:master\"));\n\n            // Approve this PR\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n\n            // Integrate\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"@user1\");\n            assertLastCommentContains(pr, \"was successfully created on the branch\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"@user2\");\n            assertLastCommentContains(pr, \"Could **not** automatically backport\");\n            assertLastCommentContains(pr, \"Below you can find a suggestion for the pull request body:\");\n\n            // Resolve conflict\n            localRepo.push(masterHash, targetRepo.authenticatedUrl(), \"master\", true);\n            // Use /backport after the pr is integrated\n            reviewerPr.addComment(\"/backport targetRepo\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"was successfully created on the branch\");\n        }\n    }\n\n    @Test\n    void testBackportCommandWhenPrIsClosed(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var targetRepo = credentials.getHostedRepository(\"targetRepo\");\n            var targetRepo2 = credentials.getHostedRepository(\"targetRepo2\");\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addReviewer(bot.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .censusRepo(censusBuilder.build())\n                    .seedStorage(seedFolder)\n                    .forks(Map.of(\"targetRepo\", targetRepo, \"targetRepo2\", targetRepo2))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Close the pr\n            pr.store().setState(Issue.State.CLOSED);\n            pr.addComment(\"/backport targetRepo\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"`/backport` command can not be used in a closed but not integrated pull request\");\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/BackportTests.java",
    "content": "/*\n * Copyright (c) 2020, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.jcheck.ReviewersCheck;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertFirstCommentContains;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\nclass BackportTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment = pr.comments().get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n\n            // Approve PR and re-run bot\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addReview(Review.Verdict.APPROVED, \"Looks good\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertFirstCommentContains(pr, \"This change now passes all *automated* pre-integration checks\");\n\n            // Integrate\n            author.pullRequest(pr.id());\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Find the commit\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            String hex = null;\n            var comment = pr.comments().getLast();\n            var lines = comment.body().split(\"\\n\");\n            var pattern = Pattern.compile(\".* Pushed as commit ([0-9a-z]{40}).*\");\n            for (var line : lines) {\n                var m = pattern.matcher(line);\n                if (m.matches()) {\n                    hex = m.group(1);\n                    break;\n                }\n            }\n            assertNotNull(hex);\n            assertEquals(40, hex.length());\n            localRepo.checkout(localRepo.defaultBranch());\n            localRepo.pull(author.authenticatedUrl().toString(), \"master\", false);\n            var commit = localRepo.lookup(new Hash(hex)).orElseThrow();\n\n            var message = CommitMessageParsers.v1.parse(commit);\n            assertEquals(1, message.issues().size());\n            assertEquals(\"An issue\", message.issues().get(0).description());\n            assertEquals(List.of(\"integrationreviewer3\"), message.reviewers());\n            assertEquals(Optional.of(releaseHash), message.original());\n            assertEquals(List.of(), message.contributors());\n            assertEquals(List.of(), message.summaries());\n            assertEquals(List.of(), message.additional());\n        }\n    }\n\n    @Test\n    void withSummary(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"This is a summary\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var comments = pr.comments();\n            var backportComment = comments.get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n\n            // Approve PR and re-run bot\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addReview(Review.Verdict.APPROVED, \"Looks good\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertFirstCommentContains(pr, \"This change now passes all *automated* pre-integration checks\");\n\n            // Integrate\n            author.pullRequest(pr.id());\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Find the commit\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            String hex = null;\n            var comment = pr.comments().getLast();\n            var lines = comment.body().split(\"\\n\");\n            var pattern = Pattern.compile(\".* Pushed as commit ([0-9a-z]{40}).*\");\n            for (var line : lines) {\n                var m = pattern.matcher(line);\n                if (m.matches()) {\n                    hex = m.group(1);\n                    break;\n                }\n            }\n            assertNotNull(hex);\n            assertEquals(40, hex.length());\n            localRepo.checkout(localRepo.defaultBranch());\n            localRepo.pull(author.authenticatedUrl().toString(), \"master\", false);\n            var commit = localRepo.lookup(new Hash(hex)).orElseThrow();\n\n            var message = CommitMessageParsers.v1.parse(commit);\n            assertEquals(1, message.issues().size());\n            assertEquals(\"An issue\", message.issues().get(0).description());\n            assertEquals(List.of(\"integrationreviewer3\"), message.reviewers());\n            assertEquals(Optional.of(releaseHash), message.original());\n            assertEquals(List.of(\"This is a summary\"), message.summaries());\n            assertEquals(List.of(), message.contributors());\n            assertEquals(List.of(), message.additional());\n        }\n    }\n\n    @Test\n    void withMultipleIssues(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue2 = credentials.createIssue(issues, \"Another issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var issue2Number = issue2.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  issue2Number + \": Another issue\\n\" +\n                                  \"\\n\" +\n                                  \"This is a summary\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment = pr.comments().get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n\n            // Approve PR and re-run bot\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addReview(Review.Verdict.APPROVED, \"Looks good\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertFirstCommentContains(pr, \"This change now passes all *automated* pre-integration checks\");\n\n            // Integrate\n            author.pullRequest(pr.id());\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Find the commit\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            String hex = null;\n            var comment = pr.comments().getLast();\n            var lines = comment.body().split(\"\\n\");\n            var pattern = Pattern.compile(\".* Pushed as commit ([0-9a-z]{40}).*\");\n            for (var line : lines) {\n                var m = pattern.matcher(line);\n                if (m.matches()) {\n                    hex = m.group(1);\n                    break;\n                }\n            }\n            assertNotNull(hex);\n            assertEquals(40, hex.length());\n            localRepo.checkout(localRepo.defaultBranch());\n            localRepo.pull(author.authenticatedUrl().toString(), \"master\", false);\n            var commit = localRepo.lookup(new Hash(hex)).orElseThrow();\n\n            var message = CommitMessageParsers.v1.parse(commit);\n            assertEquals(2, message.issues().size());\n            assertEquals(\"An issue\", message.issues().get(0).description());\n            assertEquals(\"Another issue\", message.issues().get(1).description());\n            assertEquals(List.of(\"integrationreviewer3\"), message.reviewers());\n            assertEquals(Optional.of(releaseHash), message.original());\n            assertEquals(List.of(\"This is a summary\"), message.summaries());\n            assertEquals(List.of(), message.contributors());\n            assertEquals(List.of(), message.additional());\n        }\n    }\n\n    @Test\n    void nonExitingCommit(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding backport PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport 0123456789012345678901234567890123456789\");\n\n            // The bot should reply with a backport error\n            TestBotRunner.runPeriodicItems(bot);\n            assertLastCommentContains(pr, \"<!-- backport error -->\");\n            assertLastCommentContains(pr, \":warning:\");\n            assertLastCommentContains(pr, \"could not find any commit with hash `0123456789012345678901234567890123456789`\");\n            assertFalse(pr.store().labelNames().contains(\"backport\"));\n\n            // Re-running the bot should not cause any more error comments\n            TestBotRunner.runPeriodicItems(bot);\n            assertEquals(2, pr.comments().size());\n        }\n    }\n\n    /**\n     * Tests that setting a backport title to points to the head commit of the PR\n     * itself is handled as an error.\n     */\n    @Test\n    void prHeadCommit(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                    \"\\n\" +\n                    \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            // Create the backport with the hash from the PR branch\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + editHash.hex());\n\n            // The bot should detect the bad hash\n            // The bot should reply with a backport error\n            TestBotRunner.runPeriodicItems(bot);\n            assertLastCommentContains(pr, \"<!-- backport error -->\");\n            assertLastCommentContains(pr, \":warning:\");\n            assertLastCommentContains(pr, \"the given backport hash\");\n            assertLastCommentContains(pr, \"is an ancestor of your proposed change.\");\n            assertFalse(pr.store().labelNames().contains(\"backport\"));\n\n            // Re-running the bot should not cause any more error comments\n            TestBotRunner.runPeriodicItems(bot);\n            assertEquals(2, pr.comments().size());\n        }\n    }\n\n    /**\n     * Tests that setting a backport title to points to an ancestor of the head commit of the PR\n     * itself is handled as an error.\n     */\n    @Test\n    void prAncestorOfHeadCommit(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                    \"\\n\" +\n                    \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            // Add another change on top of the backport\n            var editHash2 = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash2, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            // Create the backport with the hash from the PR branch\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + editHash.hex());\n\n            // The bot should detect the bad hash\n            // The bot should reply with a backport error\n            TestBotRunner.runPeriodicItems(bot);\n            assertLastCommentContains(pr, \"<!-- backport error -->\");\n            assertLastCommentContains(pr, \":warning:\");\n            assertLastCommentContains(pr, \"the given backport hash\");\n            assertLastCommentContains(pr, \"is an ancestor of your proposed change.\");\n            assertFalse(pr.store().labelNames().contains(\"backport\"));\n\n            // Re-running the bot should not cause any more error comments\n            TestBotRunner.runPeriodicItems(bot);\n            assertEquals(2, pr.comments().size());\n        }\n    }\n\n    @Test\n    void cleanBackport(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory(false)) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex(), List.of());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var comments = pr.comments();\n            var backportComment = comments.get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n            assertFalse(pr.store().body().contains(ReviewersCheck.DESCRIPTION), \"Reviewer requirement found in pr body\");\n            assertFalse(pr.store().body().contains(CheckRun.MSG_EMPTY_BODY), \"Body not empty requirement found in pr body\");\n\n            // The bot should have added the \"clean\" label\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n        }\n    }\n\n    @Test\n    void fuzzyCleanBackport(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory(false)) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\\n\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var masterHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\\ne\");\n            localRepo.add(newFile);\n            var issue2 = credentials.createIssue(issues, \"Another issue\");\n            var issue2Number = issue2.id().split(\"-\")[1];\n            var upstreamMessage = issue2Number + \": Another issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var upstreamHash = localRepo.commit(upstreamMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(upstreamHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            Files.writeString(newFile, \"a\\nb\\nc\\ne\\nd\\n\");\n            localRepo.add(newFile);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + upstreamHash.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var comments = pr.comments();\n            var backportComment = comments.get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + upstreamHash.hex() + \" -->\"));\n            assertEquals(issue2Number + \": Another issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n\n            // The bot should have added the \"clean\" label\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n        }\n    }\n\n    @Test\n    void notCleanBackport(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory(false)) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var masterHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\\ne\");\n            localRepo.add(newFile);\n            var issue2 = credentials.createIssue(issues, \"Another issue\");\n            var issue2Number = issue2.id().split(\"-\")[1];\n            var upstreamMessage = issue2Number + \": Another issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var upstreamHash = localRepo.commit(upstreamMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(upstreamHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\\nd\");\n            localRepo.add(newFile);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + upstreamHash.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var comments = pr.comments();\n            var backportComment = comments.get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + upstreamHash.hex() + \" -->\"));\n            assertEquals(issue2Number + \": Another issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n            assertTrue(pr.store().body().contains(ReviewersCheck.DESCRIPTION), \"Reviewer requirement not found in pr body\");\n\n            // The bot should not have added the \"clean\" label\n            assertFalse(pr.store().labelNames().contains(\"clean\"));\n        }\n    }\n\n    @Test\n    void notCleanBackportAdditionalFile(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory(false)) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var masterHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\\ne\");\n            localRepo.add(newFile);\n            var issue2 = credentials.createIssue(issues, \"Another issue\");\n            var issue2Number = issue2.id().split(\"-\")[1];\n            var upstreamMessage = issue2Number + \": Another issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var upstreamHash = localRepo.commit(upstreamMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(upstreamHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\\ne\");\n            localRepo.add(newFile);\n            var anotherFile = localRepo.root().resolve(\"another_file.txt\");\n            Files.writeString(anotherFile, \"f\\ng\\nh\\ni\");\n            localRepo.add(anotherFile);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + upstreamHash.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var comments = pr.comments();\n            var backportComment = comments.get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + upstreamHash.hex() + \" -->\"));\n            assertEquals(issue2Number + \": Another issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n\n            // The bot should not have added the \"clean\" label\n            assertFalse(pr.store().labelNames().contains(\"clean\"));\n        }\n    }\n\n    @Test\n    void cleanBackportFromCommitterCanBeIntegrated(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex());\n\n            // The bot should reply with a backport message and that the PR is ready\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment = pr.comments().get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertFirstCommentContains(pr, \"This change now passes all *automated* pre-integration checks\");\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n\n            // Integrate\n            author.pullRequest(pr.id());\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Find the commit\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            String hex = null;\n            var comment = pr.comments().getLast();\n            var lines = comment.body().split(\"\\n\");\n            var pattern = Pattern.compile(\".* Pushed as commit ([0-9a-z]{40}).*\");\n            for (var line : lines) {\n                var m = pattern.matcher(line);\n                if (m.matches()) {\n                    hex = m.group(1);\n                    break;\n                }\n            }\n            assertNotNull(hex);\n            assertEquals(40, hex.length());\n            localRepo.checkout(localRepo.defaultBranch());\n            localRepo.pull(author.authenticatedUrl().toString(), \"master\", false);\n            var commit = localRepo.lookup(new Hash(hex)).orElseThrow();\n\n            var message = CommitMessageParsers.v1.parse(commit);\n            assertEquals(1, message.issues().size());\n            assertEquals(\"An issue\", message.issues().get(0).description());\n            assertEquals(List.of(), message.reviewers());\n            assertEquals(Optional.of(releaseHash), message.original());\n            assertEquals(List.of(), message.contributors());\n            assertEquals(List.of(), message.summaries());\n            assertEquals(List.of(), message.additional());\n        }\n    }\n\n    @Test\n    void cleanBackportFromAuthorCanBeIntegrated(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex());\n\n            // The bot should reply with a backport message and that the PR is ready\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment = pr.comments().get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertFirstCommentContains(pr, \"This change now passes all *automated* pre-integration checks\");\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n            assertFalse(pr.store().labelNames().contains(\"sponsor\"));\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n\n            // Integrate\n            var prAsAuthor = author.pullRequest(pr.id());\n            prAsAuthor.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // The bot should reply with a sponsor message\n            assertTrue(pr.store().labelNames().contains(\"sponsor\"));\n\n            // Sponsor the commit\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Find the commit\n            for (var comment : pr.comments()) {\n                System.out.println(comment.body());\n            }\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            String hex = null;\n            var comment = pr.comments().getLast();\n            var lines = comment.body().split(\"\\n\");\n            var pattern = Pattern.compile(\".* Pushed as commit ([0-9a-z]{40}).*\");\n            for (var line : lines) {\n                var m = pattern.matcher(line);\n                if (m.matches()) {\n                    hex = m.group(1);\n                    break;\n                }\n            }\n            assertNotNull(hex);\n            assertEquals(40, hex.length());\n            localRepo.checkout(localRepo.defaultBranch());\n            localRepo.pull(author.authenticatedUrl().toString(), \"master\", false);\n            var commit = localRepo.lookup(new Hash(hex)).orElseThrow();\n\n            var message = CommitMessageParsers.v1.parse(commit);\n            assertNotEquals(commit.author(), commit.committer());\n            assertEquals(1, message.issues().size());\n            assertEquals(\"An issue\", message.issues().get(0).description());\n            assertEquals(List.of(), message.reviewers());\n            assertEquals(Optional.of(releaseHash), message.original());\n            assertEquals(List.of(), message.contributors());\n            assertEquals(List.of(), message.summaries());\n            assertEquals(List.of(), message.additional());\n        }\n    }\n\n    @Test\n    void whitespaceInMiddle(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport  \" + releaseHash.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment = pr.comments().get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n        }\n    }\n\n    @Test\n    void whitespaceAtEnd(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex() + \" \");\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment = pr.comments().get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n        }\n    }\n\n    @Test\n    void caseInsensitive(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"bacKporT\" + releaseHash.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment = pr.comments().get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n        }\n    }\n\n    @Test\n    void noWhitespace(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport\" + releaseHash.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment = pr.comments().get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n        }\n    }\n\n    @Test\n    void commitWithMismatchingIssueTitle(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": A issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment = pr.comments().get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n\n            // Approve PR and re-run bot\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addReview(Review.Verdict.APPROVED, \"Looks good\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertFirstCommentContains(pr, \"This change now passes all *automated* pre-integration checks\");\n\n            // Integrate\n            author.pullRequest(pr.id());\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Find the commit\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            String hex = null;\n            var comment = pr.comments().getLast();\n            var lines = comment.body().split(\"\\n\");\n            var pattern = Pattern.compile(\".* Pushed as commit ([0-9a-z]{40}).*\");\n            for (var line : lines) {\n                var m = pattern.matcher(line);\n                if (m.matches()) {\n                    hex = m.group(1);\n                    break;\n                }\n            }\n            assertNotNull(hex);\n            assertEquals(40, hex.length());\n            localRepo.checkout(localRepo.defaultBranch());\n            localRepo.pull(author.authenticatedUrl().toString(), \"master\", false);\n            var commit = localRepo.lookup(new Hash(hex)).orElseThrow();\n\n            var message = CommitMessageParsers.v1.parse(commit);\n            assertEquals(1, message.issues().size());\n            assertEquals(\"An issue\", message.issues().get(0).description());\n            assertEquals(List.of(\"integrationreviewer3\"), message.reviewers());\n            assertEquals(Optional.of(releaseHash), message.original());\n            assertEquals(List.of(), message.contributors());\n            assertEquals(List.of(), message.summaries());\n            assertEquals(List.of(), message.additional());\n        }\n    }\n\n    @Test\n    void badIssueInOriginal(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \" An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment = pr.comments().get(1).body();\n            assertTrue(backportComment.contains(\"<!-- backport error -->\"));\n            assertTrue(backportComment.contains(\"the commit `\" + releaseHash.hex() + \"` does not refer to an issue\"));\n            assertFalse(pr.store().labelNames().contains(\"backport\"));\n        }\n    }\n\n    @Test\n    void noHashOnlyIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n\n            // Create change\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n\n            // Create various kinds of bad pull request titles\n            // Use a bad project\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\",\n                    \"Backport \" + \"FOO-\" + issue1.id().split(\"-\")[1]);\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment = pr.comments().get(1).body();\n            assertTrue(backportComment.contains(\"does not match project\"));\n            assertFalse(pr.store().labelNames().contains(\"backport\"));\n\n            // Use bad issue ID\n            pr.setTitle(\"Backport TEST-4711\");\n            TestBotRunner.runPeriodicItems(bot);\n            backportComment = pr.comments().get(2).body();\n            assertTrue(backportComment.contains(\"does not exist in project\"));\n            assertFalse(pr.store().labelNames().contains(\"backport\"));\n\n            // Use different kinds of good titles\n            // Use the full issue ID\n            pr.setTitle(\"Backport \" + issue1.id());\n            TestBotRunner.runPeriodicItems(bot);\n            backportComment = pr.comments().get(3).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with the original issue\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n\n            // Case insensitive\n            pr.setTitle(\"bAcKpoRT\" + issue1.id());\n            TestBotRunner.runPeriodicItems(bot);\n            backportComment = pr.comments().get(4).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with the original issue\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n\n            // Set the title without project name\n            pr.setTitle(\"Backport \" + issue1.id().split(\"-\")[1]);\n            TestBotRunner.runPeriodicItems(bot);\n            backportComment = pr.comments().get(5).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with the original issue\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n\n            // Approve PR and re-run bot\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addReview(Review.Verdict.APPROVED, \"Looks good\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertFirstCommentContains(pr, \"This change now passes all *automated* pre-integration checks\");\n\n            // Integrate\n            var prAsCommitter = author.pullRequest(pr.id());\n            prAsCommitter.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Find the commit\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            String hex = null;\n            var comment = pr.comments().getLast();\n            var lines = comment.body().split(\"\\n\");\n            var pattern = Pattern.compile(\".* Pushed as commit ([0-9a-z]{40}).*\");\n            for (var line : lines) {\n                var m = pattern.matcher(line);\n                if (m.matches()) {\n                    hex = m.group(1);\n                    break;\n                }\n            }\n            assertNotNull(hex);\n            assertEquals(40, hex.length());\n            localRepo.checkout(localRepo.defaultBranch());\n            localRepo.pull(author.authenticatedUrl().toString(), \"master\", false);\n            var commit = localRepo.lookup(new Hash(hex)).orElseThrow();\n\n            var message = CommitMessageParsers.v1.parse(commit);\n            assertEquals(1, message.issues().size());\n            assertEquals(\"An issue\", message.issues().get(0).description());\n            assertEquals(List.of(\"integrationreviewer3\"), message.reviewers());\n            assertEquals(Optional.empty(), message.original());\n            assertEquals(List.of(), message.contributors());\n            assertEquals(List.of(), message.summaries());\n            assertEquals(List.of(), message.additional());\n        }\n    }\n\n    /**\n     * Tests that the correct original hash is used if the PR is updated with a new\n     * \"Backport HASH\" title\n     */\n    @Test\n    void updateOriginal(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                    \"\\n\" +\n                    \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // Create the same fix in another release branch\n            var releaseBranch2 = localRepo.branch(masterHash, \"release2\");\n            localRepo.checkout(releaseBranch2);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello2\");\n            localRepo.add(newFile);\n            var releaseHash2 = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash2, author.authenticatedUrl(), \"refs/heads/release2\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile3 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile3, \"hello\");\n            localRepo.add(newFile3);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment = pr.comments().get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n            assertFirstCommentContains(pr, \"This change now passes all *automated* pre-integration checks\");\n\n            // Update the PR title and use the hash from release2 instead\n            pr.setTitle(\"Backport \" + releaseHash2.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment2 = pr.comments().get(2).body();\n            assertTrue(backportComment2.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment2.contains(\"<!-- backport \" + releaseHash2.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n            // The backport is no longer clean as the release2 version of the change was different\n            assertFalse(pr.store().labelNames().contains(\"clean\"));\n            assertFirstCommentContains(pr, \"This change is no longer ready for integration - check the PR body for details\");\n\n            // Approve PR and re-run bot\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addReview(Review.Verdict.APPROVED, \"Looks good\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertFirstCommentContains(pr, \"This change now passes all *automated* pre-integration checks\");\n\n            // Integrate\n            author.pullRequest(pr.id());\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Find the commit\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            String hex = null;\n            var comment = pr.comments().getLast();\n            var lines = comment.body().split(\"\\n\");\n            var pattern = Pattern.compile(\".* Pushed as commit ([0-9a-z]{40}).*\");\n            for (var line : lines) {\n                var m = pattern.matcher(line);\n                if (m.matches()) {\n                    hex = m.group(1);\n                    break;\n                }\n            }\n            assertNotNull(hex);\n            assertEquals(40, hex.length());\n            localRepo.checkout(localRepo.defaultBranch());\n            localRepo.pull(author.authenticatedUrl().toString(), \"master\", false);\n            var commit = localRepo.lookup(new Hash(hex)).orElseThrow();\n\n            var message = CommitMessageParsers.v1.parse(commit);\n            assertEquals(1, message.issues().size());\n            assertEquals(\"An issue\", message.issues().get(0).description());\n            assertEquals(List.of(\"integrationreviewer3\"), message.reviewers());\n            // Verify that the correct original hash is present in the commit message\n            assertEquals(Optional.of(releaseHash2), message.original());\n            assertEquals(List.of(), message.contributors());\n            assertEquals(List.of(), message.summaries());\n            assertEquals(List.of(), message.additional());\n        }\n    }\n\n    @Test\n    void updateOriginalHashFromWrongToCorrect(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                    \"\\n\" +\n                    \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            var newFileSZ = localRepo.root().resolve(\"a_new_file2.txt\");\n            Files.writeString(newFileSZ, \"hello2\");\n            localRepo.add(newFileSZ);\n            var releaseHash2 =localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash2, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            // create a pr with wrong hash\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash2.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment = pr.comments().get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash2.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n            // The pr must not contain clean label\n            assertFalse(pr.store().labelNames().contains(\"clean\"));\n\n            // correct the Backport original commit Hash\n            pr.setTitle(\"Backport \"+releaseHash.hex());\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment2 = pr.comments().get(2).body();\n            assertTrue(backportComment2.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment2.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n            // The pr must contain clean label\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n\n            // Approve PR and re-run bot\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addReview(Review.Verdict.APPROVED, \"Looks good\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertFirstCommentContains(pr, \"This change now passes all *automated* pre-integration checks\");\n\n            // Integrate\n            author.pullRequest(pr.id());\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Find the commit\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            String hex = null;\n            var comment = pr.comments().getLast();\n            var lines = comment.body().split(\"\\n\");\n            var pattern = Pattern.compile(\".* Pushed as commit ([0-9a-z]{40}).*\");\n            for (var line : lines) {\n                var m = pattern.matcher(line);\n                if (m.matches()) {\n                    hex = m.group(1);\n                    break;\n                }\n            }\n            assertNotNull(hex);\n            assertEquals(40, hex.length());\n            localRepo.checkout(localRepo.defaultBranch());\n            localRepo.pull(author.authenticatedUrl().toString(), \"master\", false);\n            var commit = localRepo.lookup(new Hash(hex)).orElseThrow();\n\n            var message = CommitMessageParsers.v1.parse(commit);\n            assertEquals(1, message.issues().size());\n            assertEquals(\"An issue\", message.issues().get(0).description());\n            assertEquals(List.of(\"integrationreviewer3\"), message.reviewers());\n            assertEquals(Optional.of(releaseHash), message.original());\n            assertEquals(List.of(), message.contributors());\n            assertEquals(List.of(), message.summaries());\n            assertEquals(List.of(), message.additional());\n        }\n    }\n\n    @Test\n    void cleanBackportRequiresReview(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .reviewCleanBackport(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                    \"\\n\" +\n                    \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex());\n\n            // The bot should reply with a backport message and that the PR is not ready\n            TestBotRunner.runPeriodicItems(bot);\n            var backportComment = pr.comments().get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n            assertTrue(pr.store().body().contains(\"Change must be properly reviewed\"));\n\n            // Approve this pr as a reviewer\n            var approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n\n            // Integrate\n            author.pullRequest(pr.id());\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Find the commit\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            String hex = null;\n            var comment = pr.comments().getLast();\n            var lines = comment.body().split(\"\\n\");\n            var pattern = Pattern.compile(\".* Pushed as commit ([0-9a-z]{40}).*\");\n            for (var line : lines) {\n                var m = pattern.matcher(line);\n                if (m.matches()) {\n                    hex = m.group(1);\n                    break;\n                }\n            }\n            assertNotNull(hex);\n            assertEquals(40, hex.length());\n            localRepo.checkout(localRepo.defaultBranch());\n            localRepo.pull(author.authenticatedUrl().toString(), \"master\", false);\n            var commit = localRepo.lookup(new Hash(hex)).orElseThrow();\n\n            var message = CommitMessageParsers.v1.parse(commit);\n            assertEquals(1, message.issues().size());\n            assertEquals(\"An issue\", message.issues().get(0).description());\n            assertEquals(List.of(\"integrationreviewer3\"), message.reviewers());\n            assertEquals(Optional.of(releaseHash), message.original());\n            assertEquals(List.of(), message.contributors());\n            assertEquals(List.of(), message.summaries());\n            assertEquals(List.of(), message.additional());\n        }\n    }\n\n    @Test\n    void cleanBackportWithReviewersCommand(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory(false)) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n\n            var confPath = localRepo.root().resolve(\".jcheck/conf\");\n            var defaultConf = Files.readString(confPath);\n            var newConf = defaultConf.replace(\"reviewers=1\", \"\"\"\n                    lead=0\n                    reviewers=2\n                    committers=0\n                    authors=0\n                    contributors=0\n                    ignore=duke\n                    \"\"\");\n            Files.writeString(confPath, newConf);\n            localRepo.add(confPath);\n            var confHash = localRepo.commit(\"Change conf\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.url(), \"master\", true);\n\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.url(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                    \"\\n\" +\n                    \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.url(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.url(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex(), List.of());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var comments = pr.comments();\n            var backportComment = comments.get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n            assertFalse(pr.store().body().contains(ReviewersCheck.DESCRIPTION), \"Reviewer requirement found in pr body\");\n            assertFalse(pr.store().body().contains(CheckRun.MSG_EMPTY_BODY), \"Body not empty requirement found in pr body\");\n\n            // The bot should have added the \"clean\" label\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n\n            pr.addComment(\"/reviewers 1\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertFirstCommentContains(pr, \"This change is no longer ready for integration - check the PR body for details.\");\n            assertTrue(pr.store().body().contains(\"Change must be properly reviewed (2 reviews required\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            var reviewPr = reviewer.pullRequest(pr.id());\n            reviewPr.addReview(Review.Verdict.APPROVED, \"LGTM\");\n            var integratorPr = integrator.pullRequest(pr.id());\n            integratorPr.addReview(Review.Verdict.APPROVED, \"LGTM\");\n\n            TestBotRunner.runPeriodicItems(bot);\n            assertFirstCommentContains(pr, \"This change now passes all *automated* pre-integration checks.\");\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n\n            pr.addComment(\"/reviewers 3\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertFirstCommentContains(pr, \"This change is no longer ready for integration - check the PR body for details.\");\n            assertTrue(pr.store().body().contains(\"Change must be properly reviewed (3 reviews required\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n        }\n    }\n\n    @Test\n    void cleanBackportWithCopyrightUpdate(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory(false)) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n\n            // Initialize master branch\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"\"\"\n                    /*\n                     * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n                     * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n                     */\n                     Line1\n                     Line2\n                     \"\"\");\n            localRepo.add(newFile);\n            var updateHash = localRepo.commit(\"initial\", \"Test\", \"test@test.test\");\n            localRepo.push(updateHash, author.authenticatedUrl(), \"master\", true);\n\n            // Initialize release branch\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"\"\"\n                    /*\n                     * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n                     * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n                     */\n                     Line1\n                     Line2\n                     \"\"\");\n            localRepo.add(newFile);\n            var releaseHash = localRepo.commit(\"initial\", \"Test\", \"test@test.test\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // Update release branch\n            newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"\"\"\n                    /*\n                     * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n                     * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n                     */\n                     Line1\n                     Line2\n                     Line3\n                     \"\"\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                    \"\\n\" +\n                    \"Reviewed-by: integrationreviewer2\";\n            var updateReleaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(updateReleaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(updateHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"\"\"\n                    /*\n                     * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n                     * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n                     */\n                     Line1\n                     Line2\n                     Line3\n                     \"\"\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + updateReleaseHash.hex(), List.of());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var comments = pr.comments();\n            var backportComment = comments.get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + updateReleaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n            assertFalse(pr.store().body().contains(ReviewersCheck.DESCRIPTION), \"Reviewer requirement found in pr body\");\n            assertFalse(pr.store().body().contains(CheckRun.MSG_EMPTY_BODY), \"Body not empty requirement found in pr body\");\n\n            // The bot should have added the \"clean\" label\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n        }\n    }\n\n\n    @Test\n    void incompleteBackport(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory(false)) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                    \"\\n\" +\n                    \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex(), List.of());\n            // Force the pr to return incomplete diff\n            pr.setReturnCompleteDiff(false);\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var comments = pr.comments();\n            var backportComment = comments.get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n            assertLastCommentContains(pr, \"This backport pull request is too large to be automatically evaluated as clean.\");\n\n            // The bot should not have added the \"clean\" label\n            assertFalse(pr.store().labelNames().contains(\"clean\"));\n        }\n    }\n\n    @Test\n    void cleanBackportWithProblemListIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory(false)) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(),\n                    Path.of(\"appendable.txt\"), Set.of(\"author\", \"reviewers\", \"whitespace\", \"problemlists\"), \"0.1\");\n\n            // Add problemlists configuration to conf\n            var checkConf = tempFolder.path().resolve(\".jcheck/conf\");\n            Files.writeString(checkConf, \"\\n[checks \\\"problemlists\\\"]\\n\", StandardOpenOption.APPEND);\n            Files.writeString(checkConf, \"dirs=test/jdk\\n\", StandardOpenOption.APPEND);\n            // Create ProblemList.txt\n            Files.createDirectories(tempFolder.path().resolve(\"test/jdk\"));\n            var problemList = tempFolder.path().resolve(\"test/jdk/ProblemList.txt\");\n            Files.writeString(problemList, \"test 1 windows-all\", StandardOpenOption.CREATE);\n            localRepo.add(tempFolder.path().resolve(\".jcheck/conf\"));\n            localRepo.add(problemList);\n            localRepo.commit(\"add problemList.txt\", \"testauthor\", \"ta@none.none\");\n\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                    \"\\n\" +\n                    \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex(), List.of());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            // Should be marked as ready for review\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n            // Shouldn't be marked as ready\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/BranchCommitCommandTests.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.Branch;\nimport org.openjdk.skara.vcs.Repository;\nimport org.openjdk.skara.vcs.Reference;\nimport org.openjdk.skara.vcs.VCS;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.nio.file.Files;\nimport java.nio.file.StandardOpenOption;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class BranchCommitCommandTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .integrators(Set.of(author.forge().currentUser().username()))\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add a branch command\n            author.addCommitComment(masterHash, \"/branch next\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"branch\"));\n            assertTrue(botReply.body().contains(\"was successfully created\"));\n\n            var localAuthorRepoDir = tempFolder.path().resolve(\"author\");\n            var localAuthorRepo = Repository.clone(author.authenticatedUrl(), localAuthorRepoDir);\n            var next = new Branch(\"next\");\n            localAuthorRepo.checkout(next);\n            assertTrue(localAuthorRepo.branches().contains(next));\n            var nextHead = localAuthorRepo.lookup(next);\n            assertTrue(nextHead.isPresent());\n            assertEquals(masterHash, nextHead.get().hash());\n        }\n    }\n\n    @Test\n    void missingBranchName(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .integrators(Set.of(author.forge().currentUser().username()))\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add an empty branch command\n            author.addCommitComment(masterHash, \"/branch\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"Usage: `/branch <name>`\"));\n\n            var localAuthorRepoDir = tempFolder.path().resolve(\"author\");\n            var localAuthorRepo = Repository.clone(author.authenticatedUrl(), localAuthorRepoDir);\n        }\n    }\n\n    @Test\n    void multipleBranchNames(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .integrators(Set.of(author.forge().currentUser().username()))\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add a branch command\n            author.addCommitComment(masterHash, \"/branch a b c\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"Usage: `/branch <name>`\"));\n\n            var localAuthorRepoDir = tempFolder.path().resolve(\"author\");\n            var localAuthorRepo = Repository.clone(author.authenticatedUrl(), localAuthorRepoDir);\n            var remoteBranches = localAuthorRepo.remoteBranches(\"origin\")\n                                                .stream()\n                                                .map(Reference::name)\n                                                .collect(Collectors.toSet());\n            assertFalse(remoteBranches.contains(\"a\"));\n            assertFalse(remoteBranches.contains(\"b\"));\n            assertFalse(remoteBranches.contains(\"c\"));\n        }\n    }\n\n    @Test\n    void existingBranch(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .integrators(Set.of(author.forge().currentUser().username()))\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add a branch command\n            author.addCommitComment(masterHash, \"/branch next\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"branch\"));\n            assertTrue(botReply.body().contains(\"was successfully created\"));\n\n            var localAuthorRepoDir = tempFolder.path().resolve(\"author\");\n            var localAuthorRepo = Repository.clone(author.authenticatedUrl(), localAuthorRepoDir);\n            var next = new Branch(\"next\");\n            localAuthorRepo.checkout(next);\n            assertTrue(localAuthorRepo.branches().contains(next));\n            var nextHead = localAuthorRepo.lookup(next);\n            assertTrue(nextHead.isPresent());\n            assertEquals(masterHash, nextHead.get().hash());\n\n            // Make another commit\n            var anotherHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(anotherHash, author.authenticatedUrl(), \"master\", true);\n\n            // Try to re-create an existing branch\n            author.addCommitComment(anotherHash, \"/branch next\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            recentCommitComments = author.recentCommitComments();\n            assertEquals(4, recentCommitComments.size());\n            botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"A branch with name `next` already exists\"));\n            Pattern compilePattern = Pattern.compile(\".*\\\\[.*\\\\]\\\\(.*\\\\).*\", Pattern.MULTILINE | Pattern.DOTALL);\n            assertTrue(compilePattern.matcher(botReply.body()).matches());\n        }\n    }\n\n    @Test\n    void nonIntegrator(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add a branch command\n            author.addCommitComment(masterHash, \"/branch next\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"Only integrators for this repository are allowed to use the `/branch` command\"));\n\n            var localAuthorRepoDir = tempFolder.path().resolve(\"author\");\n            var localAuthorRepo = Repository.clone(author.authenticatedUrl(), localAuthorRepoDir);\n            var remoteBranches = localAuthorRepo.remoteBranches(\"origin\")\n                                                .stream()\n                                                .map(Reference::name)\n                                                .collect(Collectors.toSet());\n            assertFalse(remoteBranches.contains(\"next\"));\n        }\n    }\n\n    @Test\n    void nonConformingBranch(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .integrators(Set.of(author.forge().currentUser().username()))\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var jcheckConf = localRepo.root().resolve(\".jcheck\").resolve(\"conf\");\n            Files.write(jcheckConf, List.of(\"[repository]\", \"branches=foo\"), StandardOpenOption.APPEND);\n            localRepo.add(List.of(Path.of(\".jcheck\", \"conf\")));\n            localRepo.commit(\"Added branches spec\", \"testauthor\", \"ta@none.none\");\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add a branch command\n            author.addCommitComment(masterHash, \"/branch bar\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"The given branch name `bar` is not of the form `foo`\"));\n\n            var localAuthorRepoDir = tempFolder.path().resolve(\"author\");\n            var localAuthorRepo = Repository.clone(author.authenticatedUrl(), localAuthorRepoDir);\n            var remoteBranches = localAuthorRepo.remoteBranches(\"origin\")\n                                                .stream()\n                                                .map(Reference::name)\n                                                .collect(Collectors.toSet());\n            assertFalse(remoteBranches.contains(\"next\"));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/CSRBotTests.java",
    "content": "/*\n * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.issuetracker.Link;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.test.CheckableRepository;\nimport org.openjdk.skara.test.HostCredentials;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestBotRunner;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass CSRBotTests {\n    @Test\n    void removeLabelForApprovedCSR(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(issuePRMap)\n                    .build();\n            var csrIssueBot = new CSRIssueBot(issueProject, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Run issue bot once to initialize lastUpdatedAt\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n\n            var csr = issueProject.createIssue(\"This is a CSR\", List.of(), Map.of());\n            csr.setState(Issue.State.OPEN);\n            csr.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            issue.addLink(Link.create(csr, \"csr for\").build());\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Use CSRIssueBot to add CSR label\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n\n            // Approve CSR issue\n            csr.setState(Issue.State.CLOSED);\n            csr.setProperty(\"resolution\", JSON.object().put(\"name\", \"Approved\"));\n\n            // Run bot\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            // The bot should have removed the CSR label\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            assertTrue(pr.store().body().contains(\"- [x] Change requires CSR request\"));\n        }\n    }\n\n    @Test\n    void keepLabelForNoIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(bot).issueProject(issues).censusRepo(censusBuilder.build()).enableCsr(true).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is an issue\");\n\n            // Use csr command to add csr label\n            var reviewPr = reviewer.pullRequest(pr.id());\n            reviewPr.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n\n            // Run bot\n            TestBotRunner.runPeriodicItems(prBot);\n            // The bot should have kept the CSR label\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n        }\n    }\n\n    @Test\n    void keepLabelForNoJBS(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(bot).issueProject(issueProject).censusRepo(censusBuilder.build()).enableCsr(true).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is an issue\");\n\n            // Use csr command to add csr label\n            var reviewPr = reviewer.pullRequest(pr.id());\n            reviewPr.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n\n            // Run bot\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should have kept the CSR label\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n        }\n    }\n\n    @Test\n    void keepLabelForNotApprovedCSR(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var issue = issues.createIssue(\"This is an issue\", List.of(), Map.of());\n\n            var csr = issues.createIssue(\"This is an approved CSR\", List.of(), Map.of(\"resolution\",\n                    JSON.object().put(\"name\", \"Unresolved\")));\n            csr.setState(Issue.State.OPEN);\n            issue.addLink(Link.create(csr, \"csr for\").build());\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n\n            // Run bot\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot added the csr label automatically\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n\n            // Run bot\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should have kept the CSR label\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n        }\n    }\n\n    @Test\n    void handleCSRWithNullResolution(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var issue = issues.createIssue(\"This is an issue\", List.of(), Map.of());\n\n            var csr = issues.createIssue(\"This is an CSR with null resolution\", List.of(), Map.of(\"resolution\", JSON.of()));\n            csr.setState(Issue.State.OPEN);\n            issue.addLink(Link.create(csr, \"csr for\").build());\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n\n            // Run bot\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot added the csr label automatically\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n\n            // Run bot, should *not* throw NPE\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should have kept the CSR label\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n        }\n    }\n\n    @Test\n    void handleCSRWithNullName(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var issue = issues.createIssue(\"This is an issue\", List.of(), Map.of());\n\n            var csr = issues.createIssue(\"This is a CSR with null resolution\", List.of(),\n                    Map.of(\"resolution\", JSON.object().put(\"name\", JSON.of())));\n            csr.setState(Issue.State.OPEN);\n            issue.addLink(Link.create(csr, \"csr for\").build());\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n\n            // Run bot\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot added the csr label automatically\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n\n            // Run bot, should *not* throw NPE\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should have kept the CSR label\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n        }\n    }\n\n    @Test\n    void testBackportCsr(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(issuePRMap)\n                    .build();\n            var csrIssueBot = new CSRIssueBot(issueProject, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Run issue bot once to initialize lastUpdatedAt\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n\n            var issue = issueProject.createIssue(\"This is the primary issue\", List.of(), Map.of());\n            issue.setState(Issue.State.CLOSED);\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"18\"));\n\n            var csr = issueProject.createIssue(\"This is the primary CSR\", List.of(), Map.of());\n            csr.setState(Issue.State.CLOSED);\n            csr.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            csr.setProperty(\"fixVersions\", JSON.array().add(\"18\"));\n            issue.addLink(Link.create(csr, \"csr for\").build());\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Push a commit to the jdk18 branch\n            var jdk18Branch = localRepo.branch(masterHash, \"jdk18\");\n            localRepo.checkout(jdk18Branch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"a_new_file\");\n            localRepo.add(newFile);\n            var issueNumber = issue.id().split(\"-\")[1];\n            var commitMessage = issueNumber + \": This is the primary issue\\n\\nReviewed-by: integrationreviewer2\";\n            var commitHash = localRepo.commit(commitMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(commitHash, author.authenticatedUrl(), \"jdk18\", true);\n\n            // \"backport\" the commit to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"a_new_file\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + commitHash.hex());\n\n            // run bot to add backport label\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n\n            // Remove `version=0.1` from `.jcheck/conf`, set the version as null in the edit branch\n            var defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            var newConf = defaultConf.replace(\"version=0.1\", \"\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            var confHash = localRepo.commit(\"Set version as null\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"edit\", true);\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            // Run bot. The bot won't get a CSR.\n            TestBotRunner.runPeriodicItems(prBot);\n            // The bot shouldn't add the `csr` label.\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n\n            // Add `version=bla` to `.jcheck/conf`, set the version as a wrong value\n            defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            newConf = defaultConf.replace(\"project=test\", \"project=test\\nversion=bla\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            confHash = localRepo.commit(\"Set the version as a wrong value\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"edit\", true);\n            // Run bot. The bot won't get a CSR.\n            TestBotRunner.runPeriodicItems(prBot);\n            // The bot shouldn't add the `csr` label.\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n\n            // Test the method `TestPullRequest#diff`.\n            assertEquals(1, pr.diff().patches().size());\n\n            // Set the `version` in `.jcheck/conf` as 17 which is an available version.\n            defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            newConf = defaultConf.replace(\"version=bla\", \"version=17\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            confHash = localRepo.commit(\"Set the version as 17\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"edit\", true);\n            // Run bot. The primary CSR doesn't have the fix version `17`, so the bot won't get a CSR.\n            TestBotRunner.runPeriodicItems(prBot);\n            // The bot shouldn't add the `csr` label.\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n\n            // Set the fix versions of the primary CSR to 17 and 18.\n            csr.setProperty(\"fixVersions\", JSON.array().add(\"17\").add(\"18\"));\n            // Run csr issue bot to trigger on updates to the CSR issue. The primary CSR has\n            // the fix version `17`, so it would be used and the `csr` label would be added.\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            // The bot should have added the `csr` label\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n\n            // Revert the fix versions of the primary CSR to 18.\n            csr.setProperty(\"fixVersions\", JSON.array().add(\"18\"));\n            // Create a backport issue whose fix version is 17\n            var backportIssue = issueProject.createIssue(\"This is the backport issue\", List.of(), Map.of());\n            backportIssue.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            backportIssue.setProperty(\"fixVersions\", JSON.array().add(\"17\"));\n            backportIssue.setState(Issue.State.OPEN);\n            issue.addLink(Link.create(backportIssue, \"backported by\").build());\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n\n            // remove the csr label with /csr command\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/csr unneeded\");\n            // Run csrIssueBot to update pr body\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n\n            // Run bot. The bot can find a backport issue but can't find a backport CSR.\n            TestBotRunner.runPeriodicItems(prBot);\n            // The bot shouldn't add the `csr` label.\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n\n            // Create a backport CSR whose fix version is 17.\n            var backportCsr = issueProject.createIssue(\"This is the backport CSR\", List.of(), Map.of());\n            backportCsr.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            backportCsr.setProperty(\"fixVersions\", JSON.array().add(\"17\"));\n            backportCsr.setState(Issue.State.OPEN);\n            backportIssue.addLink(Link.create(backportCsr, \"csr for\").build());\n            // Run csr issue bot. The bot can find a backport issue and a backport CSR.\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            // The bot should have added the CSR label\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n\n            // Now we have a primary issue, a primary CSR, a backport issue, a backport CSR.\n            // Set the backport CSR to have multiple fix versions, included 11.\n            backportCsr.setProperty(\"fixVersions\", JSON.array().add(\"17\").add(\"11\").add(\"8\"));\n            // Set the `version` in `.jcheck/conf` as 11.\n            defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            newConf = defaultConf.replace(\"version=17\", \"version=11\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            confHash = localRepo.commit(\"Set the version as 11\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"edit\", true);\n            pr.removeLabel(\"csr\");\n            // Run bot.\n            TestBotRunner.runPeriodicItems(prBot);\n            // The bot should have added the CSR label\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n\n            // Set the backport CSR to have multiple fix versions, excluded 11.\n            backportCsr.setProperty(\"fixVersions\", JSON.array().add(\"17\").add(\"8\"));\n            reviewerPr.addComment(\"/csr unneeded\");\n            // Run csrIssueBot to update the pr body\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n\n            // Run bot.\n            TestBotRunner.runPeriodicItems(prBot);\n            // The bot shouldn't add the `csr` label.\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n        }\n    }\n\n    @Test\n    void testPRWithMultipleIssues(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot).issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(issuePRMap)\n                    .build();\n            var csrIssueBot = new CSRIssueBot(issueProject, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Run issue bot once to initialize lastUpdatedAt\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n            // Run bot\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Add another issue to this pr\n            var issue2 = issueProject.createIssue(\"This is an issue 2\", List.of(), Map.of());\n            issue2.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n\n            // Add issue2 to this pr\n            pr.addComment(\"/issue \" + issue2.id());\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().comments().getLast().body().contains(\"solves: '2'\"));\n\n            // Add a csr to issue2\n            var csr2 = issueProject.createIssue(\"This is an CSR for issue2\", List.of(), Map.of());\n            csr2.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            csr2.setState(Issue.State.OPEN);\n            issue2.addLink(Link.create(csr2, \"csr for\").build());\n\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            // PR should contain csr label\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            assertTrue(pr.store().body().contains(\"This is an CSR for issue2\"));\n\n            // Add another issue to this pr\n            var issue3 = issueProject.createIssue(\"This is an issue 3\", List.of(), Map.of());\n            issue3.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n\n            // Add issue3 to this pr\n            pr.addComment(\"/issue \" + issue3.id());\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().comments().getLast().body().contains(\"solves: '4'\"));\n\n            // Withdrawn the csr for issue2\n            csr2.setState(Issue.State.CLOSED);\n            csr2.setProperty(\"resolution\", JSON.object().put(\"name\", \"Withdrawn\"));\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            assertTrue(pr.store().body().contains(\"This is an CSR for issue2 (**CSR**) (Withdrawn)\"));\n            // PR should not contain csr label\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n\n            // Add a csr to issue3\n            var csr3 = issueProject.createIssue(\"This is an CSR for issue3\", List.of(), Map.of());\n            csr3.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            csr3.setState(Issue.State.OPEN);\n            issue3.addLink(Link.create(csr3, \"csr for\").build());\n\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            // PR should contain csr label\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n\n            // Approve CSR3\n            csr3.setState(Issue.State.CLOSED);\n            csr3.setProperty(\"resolution\", JSON.object().put(\"name\", \"Approved\"));\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            // PR should not contain csr label\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n\n            // Approve CSR2\n            csr2.setProperty(\"resolution\", JSON.object().put(\"name\", \"Approved\"));\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            // PR should not contain csr label\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n        }\n    }\n\n    @Test\n    void testFindCSRWithVersionInMergedBranch(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(issuePRMap)\n                    .build();\n            var csrIssueBot = new CSRIssueBot(issueProject, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Run issue bot once to initialize lastUpdatedAt\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n\n            var csr = issueProject.createIssue(\"This is a CSR\", List.of(), Map.of());\n            csr.setState(Issue.State.OPEN);\n            csr.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            csr.setProperty(\"fixVersions\", JSON.array().add(\"17\"));\n            issue.addLink(Link.create(csr, \"csr for\").build());\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Change .jcheck/conf in targetBranch\n            localRepo.checkout(masterHash);\n            var defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            var newConf = defaultConf.replace(\"version=0.1\", \"version=17\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            var confHash = localRepo.commit(\"Set version as 17\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"master\", true);\n\n            // The bot will be able to find the csr although fixVersion in source branch is 0.1\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n\n            reviewer.pullRequest(pr.id()).addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            assertTrue(pr.store().comments().getLast().body()\n                    .contains(\"@user2 The CSR requirement cannot be removed as CSR issues already exist. \" +\n                            \"Please withdraw [TEST-2](http://localhost/project/testTEST-2) and then use the command `/csr unneeded` again.\"));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/CSRCommandTests.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.forge.PullRequestUtils;\nimport org.openjdk.skara.forge.Review;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.Repository;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\nimport static org.openjdk.skara.bots.pr.CheckRun.CSR_PROCESS_LINK;\n\nclass CSRCommandTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var issue = issues.createIssue(\"This is an issue\", List.of(), Map.of());\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n\n            // Require CSR\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that a CSR is needed\n            assertLastCommentContains(pr, \"has indicated that a \" +\n                                          \"[compatibility and specification](\" + CSR_PROCESS_LINK + \") (CSR) request \" +\n                                          \"is needed for this pull request.\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n\n            // No longer require CSR\n            prAsReviewer.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that a CSR is no longer needed\n            assertLastCommentContains(pr, \"determined that a [CSR](\" + CSR_PROCESS_LINK + \") request \" +\n                                          \"is not needed for this pull request.\");\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            // The PR body shouldn't contain the progress about CSR request\n            assertFalse(pr.store().body().contains(\"Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n\n            // Require CSR again with long form\n            prAsReviewer.addComment(\"/csr needed\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that a CSR is needed\n            assertLastCommentContains(pr, \"has indicated that a \" +\n                                          \"[compatibility and specification](\" + CSR_PROCESS_LINK + \") (CSR) request \" +\n                                          \"is needed for this pull request.\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n        }\n    }\n\n    private String generateCSRProgressMessage(IssueTrackerIssue issue) {\n        return \"Change requires CSR request [\" + issue.id() + \"](\" + issue.webUrl() + \") to be approved\";\n    }\n\n    @Test\n    void alreadyApprovedCSR(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var issue = issues.createIssue(\"This is an issue\", List.of(), Map.of());\n\n            var csr = issues.createIssue(\"This is an approved CSR\", List.of(), Map.of());\n            csr.setState(Issue.State.CLOSED);\n            csr.setProperty(\"resolution\", JSON.object().put(\"name\", \"Approved\"));\n            csr.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            issue.addLink(Link.create(csr, \"csr for\").build());\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n\n            // Require CSR\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that the CSR is already approved\n            assertLastCommentContains(pr, \"This pull request already associated with these approved CSRs:\");\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [x] \" + generateCSRProgressMessage(csr)));\n        }\n    }\n\n    @Test\n    void testMissingIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Just a patch\");\n\n            // Require CSR\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that the CSR is already aproved\n            assertLastCommentContains(pr, \"has indicated that a \" +\n                                          \"[compatibility and specification](\" + CSR_PROCESS_LINK + \") \" +\n                                          \"(CSR) request is needed for this pull request.\");\n            assertLastCommentContains(pr, \"this pull request must refer to an issue in [JBS]\");\n            assertLastCommentContains(pr, \"To refer this pull request to an issue in JBS\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n        }\n    }\n\n    @Test\n    void requireCSRAsCommitter(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var anotherPerson = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addCommitter(anotherPerson.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Just a patch\");\n\n            // Require CSR from another person who is not a reviewer and is not the author\n            var prAsAnother = anotherPerson.pullRequest(pr.id());\n            prAsAnother.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(prAsAnother, \"only the pull request author and [Reviewers]\");\n            assertLastCommentContains(prAsAnother, \"are allowed to use the `csr` command.\");\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            // The PR body shouldn't contain the progress about CSR request\n            assertFalse(pr.store().body().contains(\"Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n\n            // Stating that a CSR is not needed should not work\n            prAsAnother.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(prAsAnother, \"only the pull request author and [Reviewers]\");\n            assertLastCommentContains(prAsAnother, \"are allowed to use the `csr` command.\");\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            // The PR body shouldn't contain the progress about CSR request\n            assertFalse(pr.store().body().contains(\"Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n\n            // Require CSR as committer\n            pr.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that a CSR is needed\n            assertLastCommentContains(pr, \"has indicated that a \" +\n                                          \"[compatibility and specification](\" + CSR_PROCESS_LINK + \") (CSR) request \" +\n                                          \"is needed for this pull request.\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n\n            // Stating that a CSR is not needed should not work\n            pr.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"only [Reviewers]\");\n            assertLastCommentContains(pr, \"can determine that a CSR is not needed.\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n\n            // Stating that a CSR is not needed should not work\n            prAsAnother.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(prAsAnother, \"only the pull request author and [Reviewers]\");\n            assertLastCommentContains(prAsAnother, \"are allowed to use the `csr` command.\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n        }\n    }\n\n    @Test\n    void showHelpMessageOnUnexpectedArg(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Just a patch\");\n\n            // Require CSR with bad argument\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addComment(\"/csr foobar\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Show help\n            assertLastCommentContains(pr, \"usage: `/csr [needed|unneeded]`, requires that the issue the pull request refers to links \" +\n                                          \"to an approved [CSR](\" + CSR_PROCESS_LINK + \") request.\");\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            // The PR body shouldn't contain the progress about CSR request\n            assertFalse(pr.store().body().contains(\"Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n        }\n    }\n\n    @Test\n    void nonExistingJBSIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is an issue\");\n\n            // Require CSR\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that the PR must refer to an issue in JBS\n            assertLastCommentContains(pr, \"has indicated that a \" +\n                                          \"[compatibility and specification](\" + CSR_PROCESS_LINK + \") \" +\n                                          \"(CSR) request is needed for this pull request.\");\n            assertLastCommentContains(pr, \"this pull request must refer to an issue in [JBS]\");\n            assertLastCommentContains(pr, \"to be able to link it to a [CSR](\" + CSR_PROCESS_LINK + \") request. To refer this pull request to an issue in JBS\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n        }\n    }\n\n    @Test\n    void csrRequestWhenCSRIsAlreadyRequested(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var issue = issues.createIssue(\"This is an issue\", List.of(), Map.of());\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n\n            // Require CSR\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that a CSR is needed\n            assertLastCommentContains(pr, \"has indicated that a \" +\n                                          \"[compatibility and specification](\" + CSR_PROCESS_LINK + \") (CSR) request \" +\n                                          \"is needed for this pull request.\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n\n            // Require a CSR again\n            prAsReviewer.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that a CSR is already required\n            assertLastCommentContains(pr, \"an approved [CSR]\");\n            assertLastCommentContains(pr, \"request is already required for this pull request.\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n        }\n    }\n\n    @Test\n    void notYetApprovedCSR(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var issue = issues.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            var csr = issues.createIssue(\"This is an approved CSR\", List.of(), Map.of(\"resolution\",\n                                                                                      JSON.object().put(\"name\", \"Unresolved\")));\n            csr.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            csr.setState(Issue.State.OPEN);\n            issue.addLink(Link.create(csr, \"csr for\").build());\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(issuePRMap)\n                    .build();\n            var csrIssueBot = new CSRIssueBot(issues, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Run issue bot once to initialize lastUpdatedAt\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n            PullRequestUtils.postPullRequestLinkComment(issue, pr);\n\n            // Require CSR\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that there is already an approved CSR request\n            // Before '/csr' is handled, csr label is added to this pr\n            assertLastCommentContains(pr, \"an approved [CSR](\" + CSR_PROCESS_LINK + \") request is already required for this pull request.\");\n            assertLastCommentContains(pr, \"<!-- csr: 'needed' -->\");\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] \" + generateCSRProgressMessage(csr)));\n\n            // Indicate the PR doesn't require CSR, but it doesn't work.\n            prAsReviewer.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message which directs the user to withdraw the csr firstly.\n            assertLastCommentContains(pr, \"The CSR requirement cannot be removed as CSR issues already exist.\");\n            assertLastCommentContains(pr, \"and then use the command `/csr unneeded` again.\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] \" + generateCSRProgressMessage(csr)));\n\n            // withdraw the csr\n            csr.setState(Issue.State.CLOSED);\n            csr.setProperty(\"resolution\", JSON.object().put(\"name\", \"Withdrawn\"));\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n\n            // Indicate the PR doesn't require CSR, now it works\n            prAsReviewer.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that a CSR is no longer needed\n            assertLastCommentContains(pr, \"determined that a [CSR](\" + CSR_PROCESS_LINK + \") request \" +\n                    \"is not needed for this pull request.\");\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            // The PR body shouldn't contain the progress about CSR request\n            assertFalse(pr.store().body().contains(generateCSRProgressMessage(csr)));\n        }\n    }\n\n    @Test\n    void csrWithNullResolution(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var issue = issues.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n\n            var csr = issues.createIssue(\"This is an approved CSR\", List.of(), Map.of(\"resolution\", JSON.of()));\n            csr.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            csr.setState(Issue.State.OPEN);\n            issue.addLink(Link.create(csr, \"csr for\").build());\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(issuePRMap)\n                    .build();\n            var csrIssueBot = new CSRIssueBot(issues, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Run issue bot once to initialize lastUpdatedAt\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n            PullRequestUtils.postPullRequestLinkComment(issue, pr);\n\n            // Require CSR\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that there is already an approved CSR request\n            // Before '/csr' is handled, csr label is added to this pr\n            assertLastCommentContains(pr, \"an approved [CSR](\" + CSR_PROCESS_LINK + \") request is already required for this pull request.\");\n            assertLastCommentContains(pr, \"<!-- csr: 'needed' -->\");\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] \" + generateCSRProgressMessage(csr)));\n\n            // Indicate the PR doesn't require CSR, but it doesn't work.\n            prAsReviewer.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message which directs the user to withdraw the csr firstly.\n            assertLastCommentContains(pr, \"The CSR requirement cannot be removed as CSR issues already exist.\");\n            assertLastCommentContains(pr, \"and then use the command `/csr unneeded` again.\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] \" + generateCSRProgressMessage(csr)));\n\n            // withdraw the csr\n            csr.setState(Issue.State.CLOSED);\n            csr.setProperty(\"resolution\", JSON.object().put(\"name\", \"Withdrawn\"));\n\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            // Indicate the PR doesn't require CSR, now it works\n            prAsReviewer.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that a CSR is no longer needed\n            assertLastCommentContains(pr, \"determined that a [CSR](\" + CSR_PROCESS_LINK + \") request \" +\n                    \"is not needed for this pull request.\");\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            // The PR body shouldn't contain the progress about CSR request\n            assertFalse(pr.store().body().contains(\"- [ ] \" + generateCSRProgressMessage(csr)));\n        }\n    }\n\n    @Test\n    void csrInPrBody(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR and require CSR in PR body\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Just a patch\", List.of(\"/csr\"));\n\n            // Run bot\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that a CSR is needed\n            assertLastCommentContains(pr, \"has indicated that a \" +\n                                          \"[compatibility and specification](\" + CSR_PROCESS_LINK + \") (CSR) request \" +\n                                          \"is needed for this pull request.\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n        }\n    }\n\n    @Test\n    void csrLabelShouldBlockReadyLabel(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Just a patch\");\n\n            // Approve the PR\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addReview(Review.Verdict.APPROVED, \"Looks good\");\n\n            // Run bot\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // PR should be ready\n            var prAsAuthor = author.pullRequest(pr.id());\n            assertTrue(prAsAuthor.labelNames().contains(\"ready\"));\n            // The PR body shouldn't contain the progress about CSR request\n            assertFalse(pr.store().body().contains(\"Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n\n            // Require CSR\n            prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addComment(\"/csr\");\n\n            // Run bot\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that a CSR is needed\n            assertLastCommentContains(pr, \"has indicated that a \" +\n                                          \"[compatibility and specification](\" + CSR_PROCESS_LINK + \") (CSR) request \" +\n                                          \"is needed for this pull request.\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n\n            // PR should not be ready\n            prAsAuthor = author.pullRequest(pr.id());\n            assertFalse(prAsAuthor.labelNames().contains(\"ready\"));\n\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n        }\n    }\n\n    @Test\n    void testEnableCsrConfig(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id());\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Just a patch\");\n\n            // Test the pull request bot with csr disable\n            var disableCsrBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .enableCsr(false)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(new HashMap<>())\n                    .build();\n            pr.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(disableCsrBot);\n            assertLastCommentContains(pr, \"This repository has not been configured to use the `csr` command.\");\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            assertFalse(pr.store().body().contains(\"Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n\n            // Test the pull request bot with csr enable\n            var enableCsrBot = PullRequestBot.newBuilder().repo(bot).issueProject(issues)\n                    .enableCsr(true).censusRepo(censusBuilder.build()).build();\n            pr.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(enableCsrBot);\n            assertLastCommentContains(pr, \"has indicated that a \" +\n                    \"[compatibility and specification](\" + CSR_PROCESS_LINK + \") (CSR) request \" +\n                    \"is needed for this pull request.\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            assertTrue(pr.store().body().contains(\"Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n        }\n    }\n\n    @Test\n    void testBackportCsr(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var bot = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n\n            var issue = issueProject.createIssue(\"This is the primary issue\", List.of(), Map.of());\n            issue.setState(Issue.State.CLOSED);\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"18\"));\n\n            var csr = issueProject.createIssue(\"This is the primary CSR\", List.of(), Map.of());\n            csr.setState(Issue.State.CLOSED);\n            csr.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            csr.setProperty(\"fixVersions\", JSON.array().add(\"18\"));\n            csr.setProperty(\"resolution\", JSON.object().put(\"name\", \"Approved\"));\n            issue.addLink(Link.create(csr, \"csr for\").build());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .enableCsr(true)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issueProject)\n                    .issuePRMap(issuePRMap)\n                    .build();\n            var csrIssueBot = new CSRIssueBot(issueProject, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Run issue prBot once to initialize lastUpdatedAt\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Push a commit to the jdk18 branch\n            var jdk18Branch = localRepo.branch(masterHash, \"jdk18\");\n            localRepo.checkout(jdk18Branch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"a_new_file\");\n            localRepo.add(newFile);\n            var issueNumber = issue.id().split(\"-\")[1];\n            var commitMessage = issueNumber + \": This is the primary issue\\n\\nReviewed-by: integrationreviewer2\";\n            var commitHash = localRepo.commit(commitMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(commitHash, author.authenticatedUrl(), \"jdk18\", true);\n\n            // Remove `version=0.1` from `.jcheck/conf`, set the version as null\n            localRepo.checkout(localRepo.defaultBranch());\n            var defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            var newConf = defaultConf.replace(\"version=0.1\", \"\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            var confHash = localRepo.commit(\"Set version as null\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"master\", true);\n            createBackport(localRepo, author, confHash, \"edit1\");\n            var pr = credentials.createPullRequest(author, \"master\", \"edit1\", \"Backport \" + commitHash);\n            PullRequestUtils.postPullRequestLinkComment(issue, pr);\n\n            // \"csr\" label should be added automatically because the main issue has a resolved CSR\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(3 ,pr.store().comments().size());\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion (No fixVersion in .jcheck/conf) to be approved (needs to be created)\"));\n            assertLastCommentContains(pr, \"this backport may also need a CSR\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(3 ,pr.store().comments().size());\n\n            // Run prBot. Request a CSR.\n            pr.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion (No fixVersion in .jcheck/conf) to be approved (needs to be created)\"));\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"@user1 an approved [CSR](\" + CSR_PROCESS_LINK + \") request is already required for this pull request.\");\n\n            // Use `/csr unneeded` to revert the change.\n            pr.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().body().contains(\"Change requires a CSR request matching fixVersion (No fixVersion in .jcheck/conf) to be approved (needs to be created)\"));\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"determined that a [CSR](\" + CSR_PROCESS_LINK + \") request \" +\n                    \"is not needed for this pull request.\");\n\n            // Run pr bot again, \"csr\" label should not be added because reviewer issued \"/csr unneeded\"\n            assertEquals(7 ,pr.store().comments().size());\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(7 ,pr.store().comments().size());\n\n            // Add `version=bla` to `.jcheck/conf`, set the version as a wrong value\n            localRepo.checkout(localRepo.defaultBranch());\n            defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            newConf = defaultConf.replace(\"project=test\", \"project=test\\nversion=bla\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            confHash = localRepo.commit(\"Set the version as a wrong value\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"master\", true);\n            createBackport(localRepo, author, confHash, \"edit2\");\n            pr = credentials.createPullRequest(author, \"master\", \"edit2\", \"Backport \" + commitHash);\n            PullRequestUtils.postPullRequestLinkComment(issue, pr);\n\n            // Run prBot. Request a CSR.\n            pr.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion (No fixVersion in .jcheck/conf) to be approved (needs to be created)\"));\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"@user1 an approved [CSR](\" + CSR_PROCESS_LINK + \") request is already required for this pull request.\");\n\n            // Use `/csr unneeded` to revert the change.\n            pr.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().body().contains(\"Change requires a CSR request matching fixVersion (No fixVersion in .jcheck/conf) to be approved (needs to be created)\"));\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"determined that a [CSR](\" + CSR_PROCESS_LINK + \") request \" +\n                    \"is not needed for this pull request.\");\n\n            // Set the `version` in `.jcheck/conf` as 17 which is an available version.\n            localRepo.checkout(localRepo.defaultBranch());\n            defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            newConf = defaultConf.replace(\"version=bla\", \"version=17\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            confHash = localRepo.commit(\"Set the version as 17\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"master\", true);\n            createBackport(localRepo, author, confHash, \"edit3\");\n            pr = credentials.createPullRequest(author, \"master\", \"edit3\", \"Backport \" + commitHash);\n            PullRequestUtils.postPullRequestLinkComment(issue, pr);\n\n            // Run prBot. Request a CSR.\n            pr.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 17 to be approved (needs to be created)\"));\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"@user1 an approved [CSR](\" + CSR_PROCESS_LINK + \") request is already required for this pull request.\");\n\n            // Use `/csr unneeded` to revert the change.\n            pr.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().body().contains(\"Change requires a CSR request matching fixVersion 17 to be approved (needs to be created)\"));\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"determined that a [CSR](\" + CSR_PROCESS_LINK + \") request \" +\n                    \"is not needed for this pull request.\");\n\n            // Set the fix versions of the primary CSR to 17 and 18.\n            csr.setProperty(\"fixVersions\", JSON.array().add(\"17\").add(\"18\"));\n            // Run csrIssueBot to update the pr body\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            // Run prBot. Request a CSR.\n            pr.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"- [x] \" + generateCSRProgressMessage(csr)));\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"This pull request already associated with these approved CSRs:\");\n            // Use `/csr unneeded`.\n            pr.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"- [x] \" + generateCSRProgressMessage(csr)));\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"The CSR requirement cannot be removed as CSR issues already exist.\");\n            assertLastCommentContains(pr, \"and then use the command `/csr unneeded` again\");\n\n            // Revert the fix versions of the primary CSR to 18.\n            csr.setProperty(\"fixVersions\", JSON.array().add(\"18\"));\n            // Create a backport issue whose fix version is 17\n            var backportIssue = issueProject.createIssue(\"This is the backport issue\", List.of(), Map.of());\n            backportIssue.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            backportIssue.setProperty(\"fixVersions\", JSON.array().add(\"17\"));\n            backportIssue.setState(Issue.State.OPEN);\n            issue.addLink(Link.create(backportIssue, \"backported by\").build());\n            // Run csrIssueBot to update the pr body\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            // Run prBot. Request a CSR.\n            pr.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 17 to be approved (needs to be created)\"));\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"@user1 an approved [CSR](\" + CSR_PROCESS_LINK + \") request is already required for this pull request.\");\n\n            // Use `/csr unneeded` to revert the change.\n            pr.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().body().contains(\"Change requires a CSR request matching fixVersion 17 to be approved (needs to be created)\"));\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"determined that a [CSR](\" + CSR_PROCESS_LINK + \") request \" +\n                    \"is not needed for this pull request.\");\n\n            // Create a backport CSR whose fix version is 17.\n            var backportCsr = issueProject.createIssue(\"This is the backport CSR\", List.of(), Map.of());\n            backportCsr.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            backportCsr.setProperty(\"fixVersions\", JSON.array().add(\"17\"));\n            backportCsr.setState(Issue.State.OPEN);\n            backportIssue.addLink(Link.create(backportCsr, \"csr for\").build());\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n\n            // Run prBot. Request a CSR.\n            pr.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"- [ ] \" + generateCSRProgressMessage(backportCsr)));\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"an approved [CSR](\" + CSR_PROCESS_LINK + \") request is already required for this pull request.\");\n            // Use `/csr unneeded` to revert the change.\n            pr.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"- [ ] \" + generateCSRProgressMessage(backportCsr)));\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"The CSR requirement cannot be removed as CSR issues already exist.\");\n            assertLastCommentContains(pr, \"and then use the command `/csr unneeded` again.\");\n\n            // Now we have a primary issue, a primary CSR, a backport issue, a backport CSR.\n            // Set the backport CSR to have multiple fix versions, included 11.\n            backportCsr.setProperty(\"fixVersions\", JSON.array().add(\"17\").add(\"11\").add(\"8\"));\n            // Set the `version` in `.jcheck/conf` as 11.\n            localRepo.checkout(localRepo.defaultBranch());\n            defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            newConf = defaultConf.replace(\"version=17\", \"version=11\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            confHash = localRepo.commit(\"Set the version as 11\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"master\", true);\n            createBackport(localRepo, author, confHash, \"edit4\");\n            pr = credentials.createPullRequest(author, \"master\", \"edit4\", \"Backport \" + commitHash);\n            PullRequestUtils.postPullRequestLinkComment(issue, pr);\n\n            // Run prBot.\n            pr.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"- [ ] \" + generateCSRProgressMessage(backportCsr)));\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"an approved [CSR](\" + CSR_PROCESS_LINK + \") request is already required for this pull request.\");\n            assertLastCommentContains(pr, \"<!-- csr: 'needed' -->\");\n            // Set the backport CSR to have multiple fix versions, excluded 11.\n            backportCsr.setProperty(\"fixVersions\", JSON.array().add(\"17\").add(\"8\"));\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            // Use `/csr unneeded` to revert the change.\n            pr.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().body().contains(\"- [ ] \" + generateCSRProgressMessage(backportCsr)));\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"determined that a [CSR](\" + CSR_PROCESS_LINK + \") request \" +\n                    \"is not needed for this pull request.\");\n\n            // re-run prBot.\n            pr.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 11 to be approved (needs to be created)\"));\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"has indicated that a \" +\n                    \"[compatibility and specification](\" + CSR_PROCESS_LINK + \") (CSR) request \" +\n                    \"is needed for this pull request.\");\n            assertLastCommentContains(pr, \"please create a [CSR](\" + CSR_PROCESS_LINK + \") request\");\n            assertLastCommentContains(pr, \"with the correct fix version\");\n            assertLastCommentContains(pr, \"This pull request cannot be integrated until the CSR request is approved.\");\n        }\n    }\n\n    private void createBackport(Repository localRepo, HostedRepository author, Hash masterHash, String branchName) throws IOException {\n        localRepo.checkout(localRepo.defaultBranch());\n        var editBranch = localRepo.branch(masterHash, branchName);\n        localRepo.checkout(editBranch);\n        var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n        Files.writeString(newFile2, \"a_new_file\");\n        localRepo.add(newFile2);\n        var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n        localRepo.push(editHash, author.authenticatedUrl(), branchName, true);\n    }\n\n    @Test\n    void prSolvesMultipleIssues(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var issue = issues.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(issuePRMap)\n                    .build();\n            var csrIssueBot = new CSRIssueBot(issues, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Run issue bot once to initialize lastUpdatedAt\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n            PullRequestUtils.postPullRequestLinkComment(issue, pr);\n\n            // Require CSR\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that a CSR is needed\n            assertLastCommentContains(pr, \"has indicated that a \" +\n                    \"[compatibility and specification](\" + CSR_PROCESS_LINK + \") (CSR) request \" +\n                    \"is needed for this pull request.\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n\n            var issue2 = issues.createIssue(\"This is an issue2\", List.of(), Map.of());\n            issue2.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            // create a csr for issue2\n            var csr2 = issues.createIssue(\"This is a CSR2\", List.of(), Map.of(\"resolution\",\n                    JSON.object().put(\"name\", \"Unresolved\")));\n            csr2.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            csr2.setState(Issue.State.OPEN);\n            issue2.addLink(Link.create(csr2, \"csr for\").build());\n            PullRequestUtils.postPullRequestLinkComment(issue2, pr);\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n\n            pr.addComment(\"/issue TEST-2\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            assertTrue(pr.store().body().contains(\"- [ ] \" + generateCSRProgressMessage(csr2)));\n\n            // Try /csr unneeded, it should fail\n            prAsReviewer.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"The CSR requirement cannot be removed as CSR issues already exist.\");\n\n            // Withdraw the csr linked with issue2\n            csr2.setState(Issue.State.CLOSED);\n            csr2.setProperty(\"resolution\", JSON.object().put(\"name\", \"Withdrawn\"));\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            prAsReviewer.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n\n            // Require CSR again\n            prAsReviewer.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a message that a CSR is needed\n            assertLastCommentContains(pr, \"has indicated that a \" +\n                    \"[compatibility and specification](\" + CSR_PROCESS_LINK + \") (CSR) request \" +\n                    \"is needed for this pull request.\");\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The PR body should contain the progress about CSR request\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n\n            // Create a csr for main issue\n            var csr1 = issues.createIssue(\"This is a CSR1\", List.of(), Map.of(\"resolution\",\n                    JSON.object().put(\"name\", \"Unresolved\")));\n            csr1.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            csr1.setState(Issue.State.OPEN);\n            issue.addLink(Link.create(csr1, \"csr for\").build());\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n        }\n    }\n\n\n    @Test\n    void prSolvesMultipleIssuesWithApprovedCSRIssues(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var issue = issues.createIssue(\"This is an issue\", List.of(), Map.of());\n            var csr = issues.createIssue(\"This is a CSR\", List.of(), Map.of());\n            csr.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            csr.setState(Issue.State.CLOSED);\n            csr.setProperty(\"resolution\", JSON.object().put(\"name\", \"Approved\"));\n            issue.addLink(Link.create(csr, \"csr for\").build());\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n\n            var issue2 = issues.createIssue(\"This is an issue2\", List.of(), Map.of());\n            // create a csr for issue2\n            var csr2 = issues.createIssue(\"This is a CSR2\", List.of(), Map.of());\n            csr2.setState(Issue.State.CLOSED);\n            csr2.setProperty(\"resolution\", JSON.object().put(\"name\", \"Approved\"));\n            csr2.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            issue2.addLink(Link.create(csr2, \"csr for\").build());\n\n            pr.addComment(\"/issue TEST-3\");\n            TestBotRunner.runPeriodicItems(prBot);\n            // Require CSR\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            assertTrue(pr.store().body().contains(\"- [x] \" + generateCSRProgressMessage(csr)));\n            assertTrue(pr.store().body().contains(\"- [x] \" + generateCSRProgressMessage(csr2)));\n        }\n    }\n\n    @Test\n    void prSolvesMultipleIssuesWithWithdrawnCSRIssues(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var issue = issues.createIssue(\"This is an issue\", List.of(), Map.of());\n            var csr = issues.createIssue(\"This is a CSR\", List.of(), Map.of());\n            csr.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            csr.setState(Issue.State.CLOSED);\n            csr.setProperty(\"resolution\", JSON.object().put(\"name\", \"Withdrawn\"));\n            issue.addLink(Link.create(csr, \"csr for\").build());\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n\n            var issue2 = issues.createIssue(\"This is an issue2\", List.of(), Map.of());\n            // create a csr for issue2\n            var csr2 = issues.createIssue(\"This is a CSR2\", List.of(), Map.of());\n            csr2.setState(Issue.State.CLOSED);\n            csr2.setProperty(\"resolution\", JSON.object().put(\"name\", \"Withdrawn\"));\n            csr2.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            issue2.addLink(Link.create(csr2, \"csr for\").build());\n\n            pr.addComment(\"/issue TEST-3\");\n            TestBotRunner.runPeriodicItems(prBot);\n            // Require CSR\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            assertLastCommentContains(pr, \"has indicated that a \" +\n                    \"[compatibility and specification](\" + CSR_PROCESS_LINK + \") (CSR) request \" +\n                    \"is needed for this pull request.\");\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 0.1 to be approved (needs to be created)\"));\n            assertFalse(pr.store().body().contains(generateCSRProgressMessage(csr)));\n            assertFalse(pr.store().body().contains(generateCSRProgressMessage(csr2)));\n        }\n    }\n\n    @Test\n    void testBackportCsrLabel(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var bot = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n\n            var issue = issueProject.createIssue(\"This is the primary issue\", List.of(), Map.of());\n            issue.setState(Issue.State.CLOSED);\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"18\"));\n\n            var csr = issueProject.createIssue(\"This is the primary CSR\", List.of(), Map.of());\n            csr.setState(Issue.State.CLOSED);\n            csr.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            csr.setProperty(\"fixVersions\", JSON.array().add(\"18\"));\n            csr.setProperty(\"resolution\", JSON.object().put(\"name\", \"Approved\"));\n            issue.addLink(Link.create(csr, \"csr for\").build());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .enableCsr(true)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issueProject)\n                    .issuePRMap(issuePRMap)\n                    .build();\n            var csrIssueBot = new CSRIssueBot(issueProject, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Run issue prBot once to initialize lastUpdatedAt\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Push a commit to the jdk18 branch\n            var jdk18Branch = localRepo.branch(masterHash, \"jdk18\");\n            localRepo.checkout(jdk18Branch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"a_new_file\");\n            localRepo.add(newFile);\n            var issueNumber = issue.id().split(\"-\")[1];\n            var commitMessage = issueNumber + \": This is the primary issue\\n\\nReviewed-by: integrationreviewer2\";\n            var commitHash = localRepo.commit(commitMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(commitHash, author.authenticatedUrl(), \"jdk18\", true);\n\n            // Create a backport issue whose fix version is 17\n            var backportIssue = issueProject.createIssue(\"This is the backport issue\", List.of(), Map.of());\n            backportIssue.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            backportIssue.setProperty(\"fixVersions\", JSON.array().add(\"17\"));\n            backportIssue.setState(Issue.State.OPEN);\n            issue.addLink(Link.create(backportIssue, \"backported by\").build());\n\n            // Create a backport CSR whose fix version is 17.\n            var backportCsr = issueProject.createIssue(\"This is the backport CSR\", List.of(), Map.of());\n            backportCsr.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            backportCsr.setProperty(\"fixVersions\", JSON.array().add(\"17\"));\n            backportCsr.setState(Issue.State.OPEN);\n            backportIssue.addLink(Link.create(backportCsr, \"csr for\").build());\n\n            // Set the `version` in `.jcheck/conf` as 17 which is an available version.\n            localRepo.checkout(localRepo.defaultBranch());\n            var defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            var newConf = defaultConf.replace(\"version=0.1\", \"version=17\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            var confHash = localRepo.commit(\"Set the version as 17\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"master\", true);\n            createBackport(localRepo, author, confHash, \"edit1\");\n            var pr = credentials.createPullRequest(author, \"master\", \"edit1\", \"Backport \" + commitHash);\n            PullRequestUtils.postPullRequestLinkComment(issue, pr);\n\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires CSR request [TEST-4](http://localhost/project/testTEST-4) to be approved\"));\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The bot shouldn't post backport csr comment because there is a backport csr\n            assertTrue(pr.store().comments().stream().noneMatch(comment -> comment.body().contains(\"this backport may also need a CSR\")));\n\n            // Change the fixVersion of the backportCSR\n            backportCsr.setProperty(\"fixVersions\", JSON.array().add(\"19\"));\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a CSR request matching fixVersion 17 to be approved (needs to be created)\"));\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The bot shouldn't post backport csr comment because csr label is still there\n            assertTrue(pr.store().comments().stream().noneMatch(comment -> comment.body().contains(\"this backport may also need a CSR\")));\n\n            // Use '/csr unneeded'\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addComment(\"/csr unneeded\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(\"csr\"));\n            // The bot shouldn't post backport csr comment because csr label has been removed by command\n            assertTrue(pr.store().comments().stream().noneMatch(comment -> comment.body().contains(\"this backport may also need a CSR\")));\n\n            // Require CSR again\n            prAsReviewer.addComment(\"/csr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(\"csr\"));\n            // The bot shouldn't post backport csr comment because csr label has been added by command\n            assertTrue(pr.store().comments().stream().noneMatch(comment -> comment.body().contains(\"this backport may also need a CSR\")));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/CheckTests.java",
    "content": "/*\n * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.issuetracker.Link;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.Branch;\nimport org.openjdk.skara.vcs.Repository;\nimport org.openjdk.skara.vcs.git.GitVersion;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.nio.file.attribute.PosixFilePermission;\nimport java.time.*;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.common.PullRequestConstants.PROGRESS_MARKER;\nimport static org.openjdk.skara.bots.common.PullRequestConstants.WEBREV_COMMENT_MARKER;\nimport static org.openjdk.skara.bots.pr.CheckWorkItem.FORCE_PUSH_MARKER;\nimport static org.openjdk.skara.bots.pr.CheckWorkItem.FORCE_PUSH_SUGGESTION;\nimport static org.openjdk.skara.issuetracker.jira.JiraProject.JEP_NUMBER;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertFirstCommentContains;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\nclass CheckTests {\n    @Test\n    void simpleCommit(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var checkBot = PullRequestBot.newBuilder()\n                                         .repo(author)\n                                         .censusRepo(censusBuilder.build())\n                                         .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                         .seedStorage(seedFolder)\n                                         .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check succeeded\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n\n            // The PR should now be ready for review\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            // Approve it as another user\n            var approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Check the status again\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The check should now be successful\n            checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            check = checks.get(\"jcheck\");\n            var checkStartTime1 = check.startedAt();\n            assertEquals(CheckStatus.SUCCESS, check.status());\n\n            // The PR should now be ready\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().body().contains(\"https://census.com/integrationreviewer2-profile\"));\n\n            // Issue \"touch\" command\n            approvalPr.addComment(\"/touch\");\n            TestBotRunner.runPeriodicItems(checkBot);\n            check = pr.checks(editHash).get(\"jcheck\");\n            var checkStartTime2 = check.startedAt();\n            assertNotEquals(checkStartTime1, checkStartTime2);\n\n            // Issue \"keepalive\"\n            approvalPr.addComment(\"/keepalive\");\n            TestBotRunner.runPeriodicItems(checkBot);\n            check = pr.checks(editHash).get(\"jcheck\");\n            var checkStartTime3 = check.startedAt();\n            assertNotEquals(checkStartTime2, checkStartTime3);\n        }\n    }\n\n    @Test\n    void whitespaceIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A line with a trailing whitespace   \");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR should not be flagged as ready for review\n            assertFalse(pr.store().labelNames().contains(\"rfr\"));\n\n            // Approve it as another user\n            var approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check failed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.FAILURE, check.status());\n\n            // The PR should not still not be flagged as ready for review\n            assertFalse(pr.store().labelNames().contains(\"rfr\"));\n\n            // Remove the trailing whitespace in a new commit\n            editHash = CheckableRepository.replaceAndCommit(localRepo, \"A line without a trailing whitespace\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n\n            // Check the status again\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR should now be ready\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n\n            // The check should now be successful\n            checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n        }\n    }\n\n    @Test\n    void multipleReviews(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var nonRecognizedReviewer = credentials.getHostedRepository();\n            var commenter = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addReviewer(commenter.forge().currentUser().id());\n\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var authorPr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Let the status bot inspect the PR\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertFalse(authorPr.store().body().contains(\"Reviewers\"));\n\n            // Approve it\n            var reviewerPr = reviewer.pullRequest(authorPr.id());\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"Reviewers\");\n            var nonRecognizedReviewerPr = nonRecognizedReviewer.pullRequest(authorPr.id());\n            nonRecognizedReviewerPr.addReview(Review.Verdict.APPROVED, \"\");\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertFalse(authorPr.store().body().contains(\"Re-review required\"));\n            assertFalse(authorPr.store().body().contains(\"Review applies to\"));\n\n            // Check that it has been approved\n            assertTrue(authorPr.store().body().contains(\"Reviewers\"));\n            assertTrue(authorPr.store().body().contains(\"Reviewers without OpenJDK IDs\"));\n\n            // Update the file after approval\n            editHash = CheckableRepository.appendAndCommit(localRepo, \"Now I've gone and changed it\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n\n            // Check that the review is flagged as stale\n            TestBotRunner.runPeriodicItems(checkBot);\n            Pattern compilePattern = Pattern.compile(\".*Review applies to \\\\[.*\\\\]\\\\(.*\\\\).*\", Pattern.MULTILINE | Pattern.DOTALL);\n            assertTrue(compilePattern.matcher(authorPr.store().body()).matches());\n\n            // Now we can approve it again\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            nonRecognizedReviewerPr.addReview(Review.Verdict.DISAPPROVED, \"Disapprove\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Check that it has been approved (once) and is no longer stale\n            assertTrue(authorPr.store().body().contains(\"Reviewers\"));\n            assertFalse(authorPr.store().body().contains(\"Reviewers without OpenJDK IDs\"));\n            assertEquals(1, authorPr.store().body().split(\"Generated Reviewer\", -1).length - 1);\n            assertTrue(authorPr.reviews().size() >= 1);\n            assertFalse(authorPr.store().body().contains(\"Note\"));\n\n            // Add a review with disapproval\n            var commenterPr = commenter.pullRequest(authorPr.id());\n            commenterPr.addReview(Review.Verdict.DISAPPROVED, \"Disapproved\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Check that it still only approved once (but two reviews) and is no longer stale\n            assertTrue(authorPr.store().body().contains(\"Reviewers\"));\n            assertEquals(1, authorPr.store().body().split(\"Generated Reviewer\", -1).length - 1);\n            assertTrue(authorPr.reviews().size() >= 2);\n            assertFalse(authorPr.store().body().contains(\"Note\"));\n\n            // No census link is set\n            var reviewerString = \"Generated Reviewer 2 (@\" + reviewer.forge().currentUser().username() + \" - **Reviewer**)\";\n            assertTrue(authorPr.store().body().contains(reviewerString));\n        }\n    }\n\n    @Test\n    void selfReview(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(author.forge().currentUser().id());\n\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var authorPr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Let the status bot inspect the PR\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertFalse(authorPr.store().body().contains(\"Reviewers\"));\n\n            // Approve it\n            authorPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Check that it has been approved\n            assertTrue(authorPr.store().body().contains(\"Reviewers\"));\n\n            // Verify that the check failed\n            var checks = authorPr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.FAILURE, check.status());\n        }\n    }\n\n    @Test\n    void updatedContentFailsCheck(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check passed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n\n            // The PR should now be ready for review\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            // Approve it as another user\n            var approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Check the status again\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The check should now be successful\n            checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n\n            // The PR should now be ready\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n\n            var addedHash = CheckableRepository.appendAndCommit(localRepo, \"trailing whitespace   \");\n            localRepo.push(addedHash, author.authenticatedUrl(), \"edit\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR is now neither ready for review nor integration\n            assertFalse(pr.store().labelNames().contains(\"rfr\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            // The check should now be failing\n            checks = pr.checks(addedHash);\n            assertEquals(1, checks.size());\n            check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.FAILURE, check.status());\n        }\n    }\n\n    @Test\n    void mergeMessage(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Get all messages up to date\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push something unrelated to master\n            localRepo.checkout(masterHash, true);\n            var unrelated = localRepo.root().resolve(\"unrelated.txt\");\n            Files.writeString(unrelated, \"Hello\");\n            localRepo.add(unrelated);\n            var unrelatedHash = localRepo.commit(\"Unrelated\", \"X\", \"x@y.z\");\n            localRepo.push(unrelatedHash, author.authenticatedUrl(), \"master\");\n\n            // Let the bot see the changes\n            pr.setBody(pr.store().body() + \"recheck\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var updated = pr.comments().stream()\n                            .filter(comment -> comment.body().contains(\"there had been 1 new commit\"))\n                            .filter(comment -> comment.body().contains(\" * \" + unrelatedHash.abbreviate()))\n                            .filter(comment -> comment.body().contains(\"automatic rebasing\"))\n                            .count();\n            assertEquals(1, updated);\n        }\n    }\n\n    @Test\n    void cannotRebase(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Get all messages up to date\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n\n            // Push something conflicting to master\n            localRepo.checkout(masterHash, true);\n            var conflictingHash = CheckableRepository.appendAndCommit(localRepo, \"This looks like a conflict\");\n            localRepo.push(conflictingHash, author.authenticatedUrl(), \"master\");\n\n            // Let the bot see the changes\n            pr.setBody(pr.store().body() + \"recheck\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should not yet post the ready for integration message\n            var updated = pr.comments().stream()\n                            .filter(comment -> comment.body().contains(\"change now passes all automated\"))\n                            .count();\n            assertEquals(0, updated);\n\n            // The PR should be flagged as outdated\n            assertTrue(pr.store().labelNames().contains(\"merge-conflict\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            // An instructional message should have been bosted\n            var help = pr.comments().stream()\n                         .filter(comment -> comment.body().contains(\"To resolve these merge conflicts\"))\n                         .count();\n            assertEquals(1, help);\n\n            // But it should still pass jcheck\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n\n            // Restore the master branch\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Let the bot see the changes\n            pr.setBody(pr.store().body() + \"recheck\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should now post an integration message\n            updated = pr.comments().stream()\n                        .filter(comment -> comment.body().contains(\"change now passes all *automated*\"))\n                        .count();\n            assertEquals(1, updated);\n\n            // The PR should not be flagged as outdated\n            assertFalse(pr.store().labelNames().contains(\"merge-conflict\"));\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n        }\n    }\n\n    @Test\n    void blockingLabel(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build()).blockingCheckLabels(Map.of(\"block\", \"Test Blocker\")).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n            pr.addLabel(\"block\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check failed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.FAILURE, check.status());\n            assertTrue(check.summary().orElseThrow().contains(\"Test Blocker\"));\n\n            // The PR should not yet be ready for review\n            assertTrue(pr.store().labelNames().contains(\"block\"));\n            assertFalse(pr.store().labelNames().contains(\"rfr\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            // Check the status again\n            pr.removeLabel(\"block\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR should now be ready for review\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n        }\n    }\n\n    @Test\n    void emptyPRBody(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder()\n                                         .repo(author)\n                                         .censusRepo(censusBuilder.build())\n                                         .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Another PR\");\n            pr.setBody(\"    \");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check failed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.FAILURE, check.status());\n            assertTrue(check.summary().orElseThrow().contains(CheckRun.MSG_EMPTY_BODY));\n\n            // Additional errors should be displayed in the body\n            assertTrue(pr.store().body().contains(\"## Error\"));\n            assertTrue(pr.store().body().contains(CheckRun.MSG_EMPTY_BODY));\n\n            // There should be an indicator of where the pr body should be entered\n            assertTrue(pr.store().body().contains(\"Replace this text with a description of your pull request\"));\n\n            // The PR should not yet be ready for review\n            assertFalse(pr.store().labelNames().contains(\"rfr\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            // Check the status again\n            pr.setBody(\"Here's that body\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR should now be ready for review\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            // The additional errors should be gone\n            assertFalse(pr.store().body().contains(\"## Error\"));\n            assertFalse(pr.store().body().contains(CheckRun.MSG_EMPTY_BODY));\n\n            // And no new helper marker\n            assertFalse(pr.store().body().contains(\"Replace this text with a description of your pull request\"));\n        }\n    }\n\n    @Test\n    void executableFile(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder()\n                                         .repo(author)\n                                         .censusRepo(censusBuilder.build())\n                                         .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(),\n                    Path.of(\"executable.exe\"), Set.of(\"reviewers\", \"executable\"), \"0.1\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            Files.writeString(tempFolder.path().resolve(\"executable1.exe\"), \"Executable file contents\");\n            Files.setPosixFilePermissions(tempFolder.path().resolve(\"executable1.exe\"), Set.of(PosixFilePermission.OWNER_EXECUTE, PosixFilePermission.OWNER_READ));\n            localRepo.add(Path.of(\"executable1.exe\"));\n            Files.writeString(tempFolder.path().resolve(\"executable2.exe\"), \"Executable file contents\");\n            Files.setPosixFilePermissions(tempFolder.path().resolve(\"executable2.exe\"), Set.of(PosixFilePermission.OWNER_EXECUTE, PosixFilePermission.OWNER_READ));\n            localRepo.add(Path.of(\"executable2.exe\"));\n            var editHash = localRepo.commit(\"Make it executable\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Another PR\");\n            pr.setBody(\"This should not be ready\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check failed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.FAILURE, check.status());\n            assertTrue(check.summary().orElseThrow().contains(\"Executable files are not allowed (file: executable1.exe)\"));\n            assertTrue(check.summary().orElseThrow().contains(\"Executable files are not allowed (file: executable2.exe)\"));\n\n            // Additional errors should be displayed in the body\n            assertTrue(pr.store().body().contains(\"## Error\"));\n            assertTrue(pr.store().body().contains(\"Executable files are not allowed (file: executable1.exe)\"));\n            assertTrue(pr.store().body().contains(\"Executable files are not allowed (file: executable2.exe)\"));\n\n            // The PR should not yet be ready for review\n            assertFalse(pr.store().labelNames().contains(\"rfr\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            // Drop that error\n            Files.setPosixFilePermissions(tempFolder.path().resolve(\"executable1.exe\"), Set.of(PosixFilePermission.OWNER_READ));\n            localRepo.add(Path.of(\"executable1.exe\"));\n            Files.setPosixFilePermissions(tempFolder.path().resolve(\"executable2.exe\"), Set.of(PosixFilePermission.OWNER_READ));\n            localRepo.add(Path.of(\"executable2.exe\"));\n            var updatedHash = localRepo.commit(\"Make it unexecutable\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(updatedHash, author.authenticatedUrl(), \"edit\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR should now be ready for review\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            // The additional errors should be gone\n            assertFalse(pr.store().body().contains(\"## Error\"));\n            assertFalse(pr.store().body().contains(\"Executable files are not allowed\"));\n        }\n    }\n\n    @Test\n    void missingReadyLabel(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build()).readyLabels(Set.of(\"good-to-go\")).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that no checks have been run\n            var checks = pr.checks(editHash);\n            assertEquals(0, checks.size());\n\n            // The PR should not yet be ready for review\n            assertFalse(pr.store().labelNames().contains(\"rfr\"));\n\n            // Check the status again\n            pr.addLabel(\"good-to-go\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR should now be ready for review\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n        }\n    }\n\n    @Test\n    void missingReadyComment(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build()).readyComments(Map.of(reviewer.forge().currentUser().username(), Pattern.compile(\"proceed\"))).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that no checks have been run\n            var checks = pr.checks(editHash);\n            assertEquals(0, checks.size());\n\n            // The PR should not yet be ready for review\n            assertFalse(pr.store().labelNames().contains(\"rfr\"));\n\n            // Check the status again\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"proceed\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR should now be ready for review\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n        }\n    }\n\n    @Test\n    void issueIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"),\n                                                     Set.of(\"issues\"), null);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check failed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.FAILURE, check.status());\n\n            // Add an issue to the title\n            pr.setTitle(\"1234: This is a pull request\");\n\n            // Check the status again\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The check should now be successful\n            checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n        }\n    }\n\n    @Test\n    void issueTitleCutOff(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"),\n                    Set.of(\"issues\"), null);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Verify that a cut-off title is corrected\n            var issue1 = issues.createIssue(\"My first issue with a very long title that is going to be cut off by the Git Forge provider\", List.of(\"Hello\"), Map.of());\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var prBadTitle =  credentials.createPullRequest(author, \"master\", \"edit\", issue1.id() + \": My OTHER issue with a very long title that is going to be cut off by …\", List.of(\"…the Git Forge provider\"), false);\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            assertTrue(prBadTitle.store().body().contains(\"Title mismatch between PR and JBS for issue\"));\n\n            var prCutOff =  credentials.createPullRequest(author, \"master\", \"edit\", issue1.id() + \" : My first issue with a very long title that is going to be cut off by …\", List.of(\"…the Git Forge provider\", \"\", \"It also has a second line!\"), false);\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            assertFalse(prCutOff.store().body().contains(\"Title mismatch between PR and JBS for issue\"));\n\n            // The PR title should contain the full issue title\n            assertEquals(\"1: My first issue with a very long title that is going to be cut off by the Git Forge provider\", prCutOff.store().title());\n            // And the body should not contain the issue title\n            assertTrue(prCutOff.store().body().startsWith(\"It also has a second line!\"));\n\n            // Verify that trailing space in issue is ignored\n            var issue2 = issues.createIssue(\"My second issue ending in space   \", List.of(\"Hello\"), Map.of());\n\n            // Make a change with a corresponding PR\n            var editHash2 = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash2, author.authenticatedUrl(), \"edit\", true);\n\n            var prCutOff2 =  credentials.createPullRequest(author, \"master\", \"edit\", issue2.id() + \": My second issue ending in space\", List.of(), false);\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR title should contain the issue title without trailing space\n            assertEquals(\"2: My second issue ending in space\", prCutOff2.store().title());\n        }\n    }\n\n    @Test\n    void issueInSummary(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"),\n                                                     Set.of(\"issues\"), null);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var issue1 = issues.createIssue(\"My first issue\", List.of(\"Hello\"), Map.of(\"issuetype\", JSON.of(\"Bug\")));\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue1.id() + \": This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The check should be successful\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n\n            // And the body should contain the issue title\n            assertTrue(pr.store().body().contains(\"My first issue\"));\n\n            // Change the issue\n            var issue2 = issues.createIssue(\"My second issue\", List.of(\"Body\"), Map.of(\"issuetype\", JSON.of(\"Bug\")));\n            pr.setTitle(issue2.id() + \": This is a pull request\");\n\n            // Check the status again\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The body should contain the updated issue title\n            assertFalse(pr.store().body().contains(\"My first issue\"));\n            assertTrue(pr.store().body().contains(\"My second issue\"));\n\n            // The PR title does not match the issue title\n            assertTrue(pr.store().body().contains(\"Title mismatch\"));\n            assertTrue(pr.store().body().contains(\"Integration blocker\"));\n\n            // Correct it\n            pr.setTitle(issue2.id() + \" - \" + issue2.title());\n\n            // Check the status again - it should now match\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertFalse(pr.store().body().contains(\"Title mismatch\"));\n            assertFalse(pr.store().body().contains(\"Integration blocker\"));\n\n            // Use an invalid issue key\n            var issueKey = issue1.id().replace(\"TEST\", \"BADPROJECT\");\n            pr.setTitle(issueKey + \": This is a pull request\");\n\n            // Check the status again\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertFalse(pr.store().body().contains(\"My first issue\"));\n            assertFalse(pr.store().body().contains(\"My second issue\"));\n            assertTrue(pr.store().body().contains(\"does not belong to the `TEST` project\"));\n\n            // Now drop the issue key\n            issueKey = issue1.id().replace(\"TEST-\", \"\");\n            pr.setTitle(issueKey + \": This is a pull request\");\n\n            // The body should now contain the updated issue title\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertTrue(pr.store().body().contains(\"My first issue\"));\n            assertFalse(pr.store().body().contains(\"My second issue\"));\n\n            // Now enter an invalid issue id\n            pr.setTitle(\"2384848: This is a pull request\");\n\n            // Check the status again\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertFalse(pr.store().body().contains(\"My first issue\"));\n            assertFalse(pr.store().body().contains(\"My second issue\"));\n            assertTrue(pr.store().body().contains(\"Failed to retrieve\"));\n\n            // The check should still be successful though\n            checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n        }\n    }\n\n    @Test\n    void issueInSummaryExternalUpdate(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"),\n                                                     Set.of(\"issues\"), null);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var issue1 = issues.createIssue(\"My first issue\", List.of(\"Hello\"), Map.of(\"issuetype\", JSON.of(\"Bug\")));\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue1.id() + \": This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The check should be successful\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n\n            // And the body should contain the issue title\n            assertTrue(pr.store().body().contains(\"My first issue\"));\n\n            // Change the issue\n            var issue2 = issues.createIssue(\"My second issue\", List.of(\"Body\"), Map.of(\"issuetype\", JSON.of(\"Bug\")));\n            pr.setTitle(issue2.id() + \": This is a pull request\");\n\n            // Check the status again\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The body should contain the updated issue title\n            assertFalse(pr.store().body().contains(\"My first issue\"));\n            assertTrue(pr.store().body().contains(\"My second issue\"));\n\n            // The PR title does not match the issue title\n            assertTrue(pr.store().body().contains(\"Title mismatch\"));\n            assertTrue(pr.store().body().contains(\"Integration blocker\"));\n\n            // Correct it\n            issue2.setTitle(\"This is a pull request\");\n\n            // Check the status again - it should still not match due to caching\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertTrue(pr.store().body().contains(\"Title mismatch\"));\n            assertTrue(pr.store().body().contains(\"Integration blocker\"));\n\n            // Ensure the check cache expires\n            checkBot.scheduleRecheckAt(pr, Instant.now().minus(Duration.ofDays(1)));\n            var currentCheck = pr.checks(editHash).get(\"jcheck\");\n            assertTrue(currentCheck.metadata().orElseThrow().contains(\":\"));\n            var outdatedMeta = currentCheck.metadata().orElseThrow().replaceAll(\":\\\\d+\", \":100\");\n            var updatedCheck = CheckBuilder.from(currentCheck)\n                                           .metadata(outdatedMeta)\n                                           .build();\n            pr.updateCheck(updatedCheck);\n\n            // Check the status again - now it should be fine\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertFalse(pr.store().body().contains(\"Title mismatch\"));\n            assertFalse(pr.store().body().contains(\"Integration blocker\"));\n        }\n    }\n\n    @Test\n    void issueWithCsr(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                .addAuthor(author.forge().currentUser().id())\n                                .addReviewer(reviewer.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issues)\n                    .censusRepo(censusBuilder.build())\n                    .enableCsr(true)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(),\n                                    Path.of(\"appendable.txt\"), Set.of(\"issues\"), null);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Set the version to 17\n            localRepo.checkout(localRepo.defaultBranch());\n            var defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            var newConf = defaultConf.replace(\"project=test\", \"project=test\\nversion=17\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            var confHash = localRepo.commit(\"Set version as 17\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"master\", true);\n\n            var mainIssue = issues.createIssue(\"The main issue\", List.of(\"main\"), Map.of(\"issuetype\", JSON.of(\"Bug\")));\n            var csrIssue = issues.createIssue(\"The csr issue\", List.of(\"csr\"), Map.of(\"issuetype\", JSON.of(\"CSR\")));\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", mainIssue.id() + \": \" + mainIssue.title());\n\n            // PR should have one issue\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertTrue(pr.store().body().contains(\"### Issue\"));\n            assertFalse(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(\"The main issue\"));\n            assertFalse(pr.store().body().contains(\"The csr issue (**CSR**)\"));\n\n            // Require CSR\n            mainIssue.addLink(Link.create(csrIssue, \"csr for\").build());\n            pr.addComment(\"/csr\");\n\n            // PR should have two issues\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertTrue(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(\"The main issue\"));\n            assertTrue(pr.store().body().contains(\"The csr issue (**CSR**)\"));\n\n            // Set the state of the csr issue to `closed`\n            csrIssue.setState(Issue.State.CLOSED);\n            // Push a commit to trigger the check which can update the PR body.\n            var newHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(newHash, author.authenticatedUrl(), \"edit\", false);\n\n            // PR should have two issues\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertTrue(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(\"The main issue\"));\n            assertTrue(pr.store().body().contains(\"The csr issue (**CSR**)\"));\n            // The csr issue state don't need to be `open`.\n            assertFalse(pr.store().body().contains(\"Issue is not open\"));\n        }\n    }\n\n    @Test\n    void testJepIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .enableJep(true)\n                    .issuePRMap(issuePRMap)\n                    .build();\n            HashMap<String, PullRequestBot> pullRequestBotMap = new HashMap<>();\n            pullRequestBotMap.put(bot.name(), prBot);\n            var issueBot = new IssueBot(issueProject, List.of(bot), pullRequestBotMap, issuePRMap);\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(),\n                    Path.of(\"appendable.txt\"), Set.of(\"issues\"), null);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var mainIssue = issueProject.createIssue(\"The main issue\", List.of(\"main\"), Map.of(\"issuetype\", JSON.of(\"Bug\")));\n            var jepIssue = issueProject.createIssue(\"The jep issue\", List.of(\"Jep body\"),\n                    Map.of(\"issuetype\", JSON.of(\"JEP\"), \"status\", JSON.object().put(\"name\", \"Submitted\"), JEP_NUMBER, JSON.of(\"123\")));\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", mainIssue.id() + \": \" + mainIssue.title());\n\n            // PR should have one issue\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"### Issue\"));\n            assertFalse(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(\"The main issue\"));\n            assertFalse(pr.store().body().contains(\"The jep issue (**JEP**)\"));\n            assertFalse(pr.store().labelNames().contains(\"jep\"));\n\n            // Run IssueBot once to initialize state for updated issues queries\n            TestBotRunner.runPeriodicItems(issueBot);\n\n            // Require jep\n            pr.addComment(\"/jep JEP-123\");\n\n            // PR should have two issues\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(\"The main issue\"));\n            assertTrue(pr.store().body().contains(\"The jep issue (**JEP**)\"));\n            assertTrue(pr.store().labelNames().contains(\"jep\"));\n\n            // Set the state of the jep issue to `Targeted`.\n            jepIssue.setProperty(\"status\", JSON.object().put(\"name\", \"Targeted\"));\n\n            // PR should have two issues even though the jep issue has been targeted\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertTrue(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(\"The main issue\"));\n            assertTrue(pr.store().body().contains(\"The jep issue (**JEP**)\"));\n            assertFalse(pr.store().labelNames().contains(\"jep\"));\n\n            // Set the state of the jep issue to `Closed` without a resolution, this\n            // should re-add the label but keep the JEP issue in the list\n            jepIssue.setState(Issue.State.CLOSED);\n            jepIssue.setProperty(\"status\", JSON.object().put(\"name\", \"Closed\"));\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertTrue(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(\"The main issue\"));\n            assertTrue(pr.store().body().contains(\"The jep issue (**JEP**)\"));\n            assertTrue(pr.store().labelNames().contains(\"jep\"));\n            // The jep issue state doesn't need to be `open`.\n            assertFalse(pr.store().body().contains(\"Issue is not open\"));\n\n            // Set the resolution to Delivered, this should remove the label\n            // PR should have two issues even though the jep issue has been Closed\n            jepIssue.setProperty(\"resolution\", JSON.object().put(\"name\", \"Delivered\"));\n\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertTrue(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(\"The main issue\"));\n            assertTrue(pr.store().body().contains(\"The jep issue (**JEP**)\"));\n            assertFalse(pr.store().labelNames().contains(\"jep\"));\n        }\n    }\n\n    @Test\n    void cancelCheck(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Verify no checks exists\n            var checks = pr.checks(editHash);\n            assertEquals(0, checks.size());\n\n            // Create a check that is running\n            var original = CheckBuilder.create(\"jcheck\", editHash)\n                                       .title(\"jcheck title\")\n                                       .summary(\"jcheck summary\")\n                                       .build();\n            pr.createCheck(original);\n\n            // Verify check is created\n            checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var retrieved = checks.get(\"jcheck\");\n            assertEquals(\"jcheck title\", retrieved.title().get());\n            assertEquals(\"jcheck summary\", retrieved.summary().get());\n            assertEquals(CheckStatus.IN_PROGRESS, retrieved.status());\n\n            // Cancel the check\n            var cancelled = CheckBuilder.from(retrieved).cancel().build();\n            pr.updateCheck(cancelled);\n            checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            retrieved = checks.get(\"jcheck\");\n            assertEquals(\"jcheck title\", retrieved.title().get());\n            assertEquals(\"jcheck summary\", retrieved.summary().get());\n            assertEquals(CheckStatus.CANCELLED, retrieved.status());\n        }\n    }\n\n    @Test\n    void rebaseBeforeCheck(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Enable a new check in the target branch\n            localRepo.checkout(masterHash, true);\n            CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"),\n                                     Set.of(\"author\", \"reviewers\", \"whitespace\", \"issues\"), null);\n            var headHash = localRepo.resolve(\"HEAD\").orElseThrow();\n            localRepo.push(headHash, author.authenticatedUrl(), \"master\", true);\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check failed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertTrue(check.summary().orElseThrow().contains(\"commit message does not reference any issue\"));\n            assertEquals(CheckStatus.FAILURE, check.status());\n\n            // Adjust the title to conform and check the status again\n            pr.setTitle(\"12345: This is a pull request\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check passed\n            checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n        }\n    }\n\n    @Test\n    void draft(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\",\n                                                   \"This is a pull request\", true);\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check succeeded\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n\n            // The PR should still not be ready for review as it is a draft\n            assertFalse(pr.store().labelNames().contains(\"rfr\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n        }\n    }\n\n    @Test\n    void excessiveFailures(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR containing more errors than at least GitHub can handle in a check\n            var badContent = \"\\tline   \\n\".repeat(200);\n            var editHash = CheckableRepository.appendAndCommit(localRepo, badContent);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\",\n                                                   \"This is a pull request\", true);\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check failed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.FAILURE, check.status());\n        }\n    }\n\n    @Test\n    void invalidUpdatedJCheckConf(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Break the jcheck configuration on the \"edit\" branch\n            var confPath = tempFolder.path().resolve(\".jcheck/conf\");\n            Files.writeString(confPath, \"Hello there!\");\n            localRepo.add(confPath);\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A change\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\",\n                                                   \"This is a pull request\", true);\n\n            // Check the status - should throw because in edit hash, .jcheck/conf is updated and it will trigger source jcheck\n            assertThrows(RuntimeException.class, () -> TestBotRunner.runPeriodicItems(checkBot));\n            assertThrows(RuntimeException.class, () -> TestBotRunner.runPeriodicItems(checkBot));\n            assertThrows(RuntimeException.class, () -> TestBotRunner.runPeriodicItems(checkBot));\n\n            // Verify that the check failed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.FAILURE, check.status());\n            assertEquals(\"line 0: entry must be of form 'key = value'\", check.summary().get());\n            assertEquals(\"Exception occurred during source jcheck - the operation will be retried\", check.title().get());\n        }\n    }\n\n    @Test\n    void noCommit(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"master\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check failed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.FAILURE, check.status());\n            assertEquals(\"- This PR contains no changes\", check.summary().orElseThrow());\n        }\n    }\n\n    @Test\n    void redundantCommit(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make the same change with different messages in master and edit\n            String identicalChangeBody = \"identical change\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, identicalChangeBody, \"edit message\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            localRepo.checkout(masterHash, true);\n            masterHash = CheckableRepository.appendAndCommit(localRepo, identicalChangeBody, \"master message\");\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Create PR\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check failed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.FAILURE, check.status());\n            assertEquals(\"- This PR only contains changes already present in the target\", check.summary().orElseThrow());\n        }\n    }\n\n    @Test\n    void useStaleReviews(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var reviewer2 = credentials.getHostedRepository();\n            var reviewer3 = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addReviewer(reviewer2.forge().currentUser().id())\n                    .addReviewer(reviewer3.forge().currentUser().id());\n\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build()).useStaleReviews(false).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A line with\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            var approvalPr = reviewer.pullRequest(pr.id());\n            var approvalPr2 = reviewer2.pullRequest(pr.id());\n            var approvalPr3 = reviewer3.pullRequest(pr.id());\n\n            // Approve it as another user\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            approvalPr2.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR should be flagged as ready\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n            assertFalse(pr.store().body().contains(\"Re-review required\"));\n            assertFalse(pr.store().body().contains(\"Review applies to\"));\n\n            // Add another commit\n            editHash = CheckableRepository.replaceAndCommit(localRepo, \"Another line\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n\n            // Check the status again\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR should no longer be ready, as the reviews are stale\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n            assertTrue(pr.store().body().contains(\"🔄 Re-review required\"));\n\n            // Approve again by reviewer1\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved again\");\n\n            // Check the status again\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            assertFalse(pr.store().body().contains(\"Re-review required\"));\n            assertFalse(pr.store().body().contains(\"⚠️ Review applies to\"));\n            assertTrue(pr.store().body().contains(\"Review applies to\"));\n\n            // Change the target ref of the PR\n            localRepo.push(masterHash, author.authenticatedUrl(), \"other-branch\", true);\n            pr.setTargetRef(\"other-branch\");\n\n            // Check the status again\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR should no longer be ready, as the review is stale\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n            assertTrue(pr.store().body().contains(\"🔄 Re-review required\"));\n\n            // Approve yet again\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved again\");\n            approvalPr3.addReview(Review.Verdict.APPROVED, \"Approved when target ref is other-branch\");\n\n            // Check the status again\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR should be flagged as ready\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n            assertFalse(pr.store().body().contains(\"🔄 Re-review required\"));\n            assertTrue(pr.store().body().contains(\"Review was made when pull request targeted\"));\n\n            // Change target ref back to the original branch\n            pr.setTargetRef(\"master\");\n\n            // Check the status again\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR should be flagged as ready, since the old review with that target is now valid again\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().body().contains(\"Review applies to\"));\n            assertTrue(pr.store().body().contains(\"Review was made when pull request targeted\"));\n            // Credit line should include reviewers with stale reviews\n            assertLastCommentContains(pr, \"Reviewed-by: integrationreviewer2, integrationreviewer3, integrationreviewer4\");\n        }\n    }\n\n    @Test\n    void acceptSimpleMerges(TestInfo testInfo) throws IOException {\n        var v = GitVersion.get();\n        Assumptions.assumeTrue(v.major() > 2 || (v.major() == 2 && v.minor() >= 36), v.toString());\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .useStaleReviews(false)\n                    .acceptSimpleMerges(true)\n                    .build();\n\n            // create the repo using CheckableRepository, as it creates probably useful files, such as .jcheck/conf\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            // replace the default file with a bigger file for auto-merging purposes\n            localRepo.checkout(new Branch(\"master\"));\n            Path f = localRepo.root().resolve(\"file.txt\");\n            Files.writeString(f, \"\"\"\n                    0\n                    1\n                    2\n                    3\n                    4\n                    5\n                    6\n                    7\n                    8\n                    9\n                    a\n                    b\n                    c\n                    d\n                    e\n                    f\n                    \"\"\");\n            localRepo.add(f);\n            var masterHash = localRepo.commit(\"master 1\", author.name(), \"someone@example.com\");\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.branch(masterHash, \"feature\");\n            localRepo.checkout(new Branch(\"feature\"));\n            Files.writeString(f, \"\"\"\n                    1\n                    2\n                    3\n                    4\n                    5\n                    6\n                    7\n                    8\n                    9\n                    a\n                    b\n                    c\n                    d\n                    e\n                    f\n                    \"\"\");\n            localRepo.add(f);\n            var featureHash = localRepo.commit(\"feature 1\", author.name(), \"author@example.com\");\n            localRepo.push(featureHash, author.authenticatedUrl(), \"feature\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"feature\", \"This is a pull request\");\n\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            var approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n            assertFalse(pr.store().body().contains(\"Re-review required\"));\n            assertFalse(pr.store().body().contains(\"Review applies to\"));\n\n            localRepo.checkout(new Branch(\"master\"));\n            Files.writeString(f, \"\"\"\n                    0\n                    1\n                    2\n                    3\n                    4\n                    5\n                    6\n                    7\n                    8\n                    9\n                    a\n                    b\n                    c\n                    d\n                    e\n                    \"\"\");\n            localRepo.add(f);\n            masterHash = localRepo.commit(\"master 2\", author.name(), \"someone@example.com\");\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n            assertFalse(pr.store().body().contains(\"Re-review required\"));\n            assertFalse(pr.store().body().contains(\"Review applies to\"));\n\n            localRepo.checkout(new Branch(\"feature\"));\n            localRepo.merge(new Branch(\"master\"));\n            localRepo.add(f);\n            var mergeHash = localRepo.commit(\"Updated from master\", author.name(), \"author@example.com\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"feature\", true);\n\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n            assertFalse(pr.store().body().contains(\" ⚠️ Review applies to\"));\n            assertTrue(pr.store().body().contains(\"Review applies to\"));\n\n            localRepo.checkout(new Branch(\"master\"));\n            Files.writeString(f, \"\"\"\n                    0\n                    1\n                    2\n                    3\n                    4\n                    5\n                    6\n                    7\n                    8\n                    9\n                    a\n                    b\n                    c\n                    d\n                    \"\"\");\n            localRepo.add(f);\n            masterHash = localRepo.commit(\"master 3\", author.name(), \"someone@example.com\");\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n            assertFalse(pr.store().body().contains(\" ⚠️ Review applies to\"));\n            assertTrue(pr.store().body().contains(\"Review applies to\"));\n\n            localRepo.checkout(new Branch(\"feature\"));\n            Files.writeString(f, \"\"\"\n                    2\n                    3\n                    4\n                    5\n                    6\n                    7\n                    8\n                    9\n                    a\n                    b\n                    c\n                    d\n                    e\n                    \"\"\");\n            localRepo.add(f);\n            featureHash = localRepo.commit(\"feature 2\", author.name(), \"author@example.com\");\n            localRepo.push(featureHash, author.authenticatedUrl(), \"feature\", true);\n\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            assertFalse(pr.store().body().contains(\"Review applies to\"));\n            assertTrue(pr.store().body().contains(\" 🔄 Re-review required\"));\n\n            approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n            assertFalse(pr.store().body().contains(\"Review applies to\"));\n            assertFalse(pr.store().body().contains(\"Re-review required\"));\n\n            localRepo.merge(new Branch(\"master\"));\n            localRepo.add(f);\n            mergeHash = localRepo.commit(\"Updated from master 2\", author.name(), \"author@example.com\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"feature\", true);\n\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n            assertFalse(pr.store().body().contains(\" ⚠️ Review applies to\"));\n            assertTrue(pr.store().body().contains(\"Review applies to\"));\n\n            localRepo.checkout(new Branch(\"master\"));\n            Files.writeString(f, \"\"\"\n                    0\n                    1\n                    2\n                    3\n                    4\n                    5\n                    6\n                    7\n                    8\n                    9\n                    a\n                    b\n                    c\n                    \"\"\");\n            localRepo.add(f);\n            masterHash = localRepo.commit(\"master 4\", author.name(), \"someone@example.com\");\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n\n            localRepo.checkout(new Branch(\"feature\"));\n            localRepo.merge(new Branch(\"master\"));\n            localRepo.add(f);\n            mergeHash = localRepo.commit(\"Updated from master 3\", author.name(), \"author@example.com\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"feature\", true);\n\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n\n            localRepo.checkout(new Branch(\"master\"));\n            Files.writeString(f, \"\"\"\n                    0\n                    1\n                    2\n                    3\n                    4\n                    5\n                    6\n                    7\n                    8\n                    9\n                    a\n                    b\n                    \"\"\");\n            localRepo.add(f);\n            masterHash = localRepo.commit(\"master 5\", author.name(), \"someone@example.com\");\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n\n            localRepo.checkout(new Branch(\"feature\"));\n            localRepo.merge(new Branch(\"master\"));\n            Files.writeString(f, \"\"\"\n                    w\n                    x\n                    y\n                    z\n                    \"\"\");\n            localRepo.add(f);\n            mergeHash = localRepo.commit(\"Updated from master 4\", author.name(), \"author@example.com\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"feature\", true);\n\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().body().contains(\" 🔄 Re-review required\"));\n        }\n    }\n\n    @Test\n    void targetBranchPattern(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder().repo(author).censusRepo(censusBuilder.build())\n                                         .allowedTargetBranches(\"^(?!master$).*\")\n                                         .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"notmaster\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\",\n                                                   \"This is a pull request\", true);\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check failed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.FAILURE, check.status());\n            assertTrue(check.summary().orElseThrow().contains(\"The branch `master` is not allowed as target branch\"));\n            assertTrue(check.summary().orElseThrow().contains(\"notmaster\"));\n\n            var anotherPr = credentials.createPullRequest(author, \"notmaster\", \"edit\",\n                                                   \"This is a pull request\", true);\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Approve it as another user\n            var approvalPr = reviewer.pullRequest(anotherPr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check passed\n            checks = anotherPr.checks(editHash);\n            assertEquals(1, checks.size());\n            check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n        }\n    }\n\n    @Test\n    void allowedIssueTypes(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            var bug = issues.createIssue(\"My first bug\", List.of(\"A bug\"),\n                                         Map.of(\"issuetype\", JSON.of(\"Bug\")));\n            var backport = issues.createIssue(\"My first feature\", List.of(\"A feature\"),\n                                              Map.of(\"issuetype\", JSON.of(\"Backport\")));\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var bugHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(bugHash, author.authenticatedUrl(), \"bug\", true);\n            var bugPR = credentials.createPullRequest(author, \"master\", \"bug\",\n                                                      bug.id() + \": My first bug\", true);\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check passed\n            var bugChecks = bugPR.checks(bugHash);\n            assertEquals(1, bugChecks.size());\n            var bugCheck = bugChecks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, bugCheck.status());\n\n            // Make a change with a corresponding PR\n            var backportHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(backportHash, author.authenticatedUrl(), \"backport\", true);\n            var backportPR = credentials.createPullRequest(author, \"master\", \"backport\",\n                                                           backport.id() + \": My first backport\", true);\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertTrue(backportPR.store().body().contains(backport.id()));\n            assertTrue(backportPR.store().body().contains(\"My first feature\"));\n            assertTrue(backportPR.store().body().contains(\"### Integration blocker\"));\n            assertTrue(backportPR.store().body().contains(\"Issue of type `Backport` is not allowed for integrations\"));\n        }\n    }\n\n    @Test\n    void expandTitleWithNumericIssueId(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            var bug = issues.createIssue(\"My first bug\", List.of(\"A bug\"), Map.of());\n            var numericId = bug.id().split(\"-\")[1];\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var bugHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(bugHash, author.authenticatedUrl(), \"bug\", true);\n            var bugPR = credentials.createPullRequest(author, \"master\", \"bug\", numericId, true);\n            assertEquals(numericId, bugPR.store().title());\n\n            // Check the status (should expand title)\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the title is expanded\n            assertEquals(numericId + \": \" + bug.title(), bugPR.store().title());\n        }\n    }\n\n    @Test\n    void expandTitleWithIssueId(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            var bug = issues.createIssue(\"My first bug\", List.of(\"A bug\"), Map.of());\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var bugHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(bugHash, author.authenticatedUrl(), \"bug\", true);\n            var bugPR = credentials.createPullRequest(author, \"master\", \"bug\", bug.id(), true);\n            assertEquals(bug.id(), bugPR.store().title());\n\n            // Check the status (should expand title)\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the title is expanded\n            var numericId = bug.id().split(\"-\")[1];\n            assertEquals(numericId + \": \" + bug.title(), bugPR.store().title());\n        }\n    }\n\n    @Test\n    void expandInvalidTitleWithNumericIssueId(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var issuePRMap = new HashMap<String, List<PRRecord>>();\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .issuePRMap(issuePRMap)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .build();\n\n            var bug = issues.createIssue(\"My first bug\", List.of(\"A bug\"), Map.of());\n            var numericId = bug.id().split(\"-\")[1];\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(),\n                                                     Path.of(\"appendable.txt\"), Set.of(\"issues\"), \"0.9\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var bugHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(bugHash, author.authenticatedUrl(), \"bug\", true);\n\n            var bugPR = credentials.createPullRequest(author, \"master\", \"bug\", \"bad title\", true);\n\n            // Check the status (should not expand title)\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertEquals(\"bad title\", bugPR.store().title());\n            assertEquals(CheckStatus.FAILURE, bugPR.checks(bugHash).get(\"jcheck\").status());\n            assertTrue(bugPR.checks(bugHash).get(\"jcheck\").summary().get().contains(\"The commit message does not reference any issue\"));\n\n            // Now update it\n            bugPR.setTitle(numericId);\n            assertEquals(numericId, bugPR.store().title());\n\n            // Check the status (should expand title)\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertEquals(CheckStatus.SUCCESS, bugPR.checks(bugHash).get(\"jcheck\").status());\n\n            // Verify that the title is expanded\n            assertEquals(numericId + \": \" + bug.title(), bugPR.store().title());\n\n            // Now update pr title to non-canonical form\n            bugPR.setTitle(bug.id() + \" \" + bug.title());\n            TestBotRunner.runPeriodicItems(checkBot);\n            // Verify that the title is in canonical form\n            assertEquals(numericId + \": \" + bug.title(), bugPR.store().title());\n\n            // Now update pr title to another non-canonical form\n            bugPR.setTitle(bug.id() + \": \" + bug.title());\n            TestBotRunner.runPeriodicItems(checkBot);\n            // Verify that the title is in canonical form\n            assertEquals(numericId + \": \" + bug.title(), bugPR.store().title());\n        }\n    }\n\n    @Test\n    void removeNonBreakableSpaceInTitle(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            var bug = issues.createIssue(\"My first bug\", List.of(\"A bug\"), Map.of());\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var bugHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(bugHash, author.authenticatedUrl(), \"bug\", true);\n            var bugPR = credentials.createPullRequest(author, \"master\", \"bug\",\n                    bug.id() + \":\\u00A0\" + bug.title(), true);\n\n            // Check the status (should expand title)\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the title is expanded\n            var numericId = bug.id().split(\"-\")[1];\n            assertEquals(numericId + \": \" + bug.title(), bugPR.store().title());\n        }\n    }\n\n    @Test\n    void overrideJcheckConf(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var conf = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder()\n                                         .repo(author)\n                                         .censusRepo(censusBuilder.build())\n                                         .confOverrideRepo(conf)\n                                         .confOverrideName(\"jcheck.conf\")\n                                         .confOverrideRef(\"jcheck-branch\")\n                                         .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Create a different conf on a different branch\n            var defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            var newConf = defaultConf.replace(\"reviewers=1\", \"reviewers=0\");\n            Files.writeString(localRepo.root().resolve(\"jcheck.conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\"jcheck.conf\"));\n            var confHash = localRepo.commit(\"Separate conf\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"jcheck-branch\", true);\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var testHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(testHash, author.authenticatedUrl(), \"test\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"test\", \"This is a PR\");\n\n            // Check the status (should become ready immediately as reviewercount is overridden to 0)\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertEquals(Set.of(\"rfr\", \"ready\"), new HashSet<>(pr.store().labelNames()));\n        }\n    }\n\n    @Test\n    void overrideNonexistingJcheckConf(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var conf = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder()\n                                         .repo(author)\n                                         .censusRepo(censusBuilder.build())\n                                         .confOverrideRepo(conf)\n                                         .confOverrideName(\"jcheck.conf\")\n                                         .confOverrideRef(\"jcheck-branch\")\n                                         .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Create a different conf on a different branch\n            var defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            var newConf = defaultConf.replace(\"reviewers=1\", \"reviewers=0\");\n            Files.writeString(localRepo.root().resolve(\"jcheck.conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\"jcheck.conf\"));\n            var confHash = localRepo.commit(\"Separate conf\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"jcheck-branch\", true);\n            localRepo.checkout(masterHash, true);\n\n            // Remove the default one\n            localRepo.remove(localRepo.root().resolve(\".jcheck/conf\"));\n            var newMasterHash = localRepo.commit(\"No more conf\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(newMasterHash, author.authenticatedUrl(), \"master\");\n\n            // Make a change with a corresponding PR\n            var testHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(testHash, author.authenticatedUrl(), \"test\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"test\", \"This is a PR\");\n\n            // Check the status (should become ready immediately as reviewercount is overridden to 0)\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertEquals(Set.of(\"rfr\", \"ready\"), new HashSet<>(pr.store().labelNames()));\n        }\n    }\n\n    @Test\n    void differentAuthors(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var committer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(committer.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(reviewer).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with an empty e-mail\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Content\", \"A commit\", \"A Random User\", \"a.random.user@foo.com\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve the PR\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            prAsReviewer.addReview(Review.Verdict.APPROVED, \"Looks good\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should respond with an integration message and a warning about different authors\n            var comments = pr.comments();\n            var numComments = comments.size();\n            assertLastCommentContains(pr, \"the full name on your profile does not match the author name\");\n            assertFirstCommentContains(pr, \"This change now passes all *automated* pre-integration checks.\");\n\n            // Run the bot again, should not result in any new comments\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertEquals(numComments, pr.comments().size());\n        }\n    }\n\n    @Test\n    void testBackportCsr(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var botRepo = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(botRepo)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issueProject)\n                    .enableCsr(true)\n                    .issuePRMap(issuePRMap)\n                    .build();\n            var csrIssueBot = new CSRIssueBot(issueProject, List.of(author), Map.of(\"test\", prBot), issuePRMap);\n\n            // Run issue bot once to initialize lastUpdatedAt\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n\n            var issue = issueProject.createIssue(\"This is the primary issue\", List.of(), Map.of());\n            issue.setState(Issue.State.CLOSED);\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"18\"));\n\n            var csr = issueProject.createIssue(\"This is the primary CSR\", List.of(), Map.of());\n            csr.setState(Issue.State.CLOSED);\n            csr.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            csr.setProperty(\"fixVersions\", JSON.array().add(\"18\"));\n            issue.addLink(Link.create(csr, \"csr for\").build());\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Push a commit to the jdk18 branch\n            var jdk18Branch = localRepo.branch(masterHash, \"jdk18\");\n            localRepo.checkout(jdk18Branch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"a_new_file\");\n            localRepo.add(newFile);\n            var issueNumber = issue.id().split(\"-\")[1];\n            var commitMessage = issueNumber + \": This is the primary issue\\n\\nReviewed-by: integrationreviewer2\";\n            var commitHash = localRepo.commit(commitMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(commitHash, author.authenticatedUrl(), \"jdk18\", true);\n\n            // \"backport\" the commit to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"a_new_file\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + commitHash);\n            PullRequestUtils.postPullRequestLinkComment(issue, pr);\n\n            // Remove `version=0.1` from `.jcheck/conf`, set the version as null\n            var defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            var newConf = defaultConf.replace(\"version=0.1\", \"\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            var confHash = localRepo.commit(\"Set version as null\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"edit\", true);\n            // Run bot. The bot won't get a CSR.\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            // The PR should have primary issue and shouldn't have primary CSR.\n            assertTrue(pr.store().body().contains(\"### Issue\"));\n            assertFalse(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(issue.id()));\n            assertTrue(pr.store().body().contains(issue.title()));\n            assertFalse(pr.store().body().contains(csr.id()));\n            assertFalse(pr.store().body().contains(csr.title()));\n\n            // Add `version=bla` to `.jcheck/conf`, set the version as a wrong value\n            defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            newConf = defaultConf.replace(\"project=test\", \"project=test\\nversion=bla\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            confHash = localRepo.commit(\"Set the version as a wrong value\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"edit\", true);\n            // Run bot. The bot won't get a CSR.\n            TestBotRunner.runPeriodicItems(prBot);\n            // The PR should have primary issue and shouldn't have primary CSR.\n            assertTrue(pr.store().body().contains(\"### Issue\"));\n            assertFalse(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(issue.id()));\n            assertTrue(pr.store().body().contains(issue.title()));\n            assertFalse(pr.store().body().contains(csr.id()));\n            assertFalse(pr.store().body().contains(csr.title()));\n\n            // Set the `version` in `.jcheck/conf` as 17 which is an available version.\n            defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            newConf = defaultConf.replace(\"version=bla\", \"version=17\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            confHash = localRepo.commit(\"Set the version as 17\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"edit\", true);\n            // Run bot. The primary CSR doesn't have the fix version `17`, so the bot won't get a CSR.\n            TestBotRunner.runPeriodicItems(prBot);\n            // The PR should have primary issue and shouldn't have primary CSR.\n            assertTrue(pr.store().body().contains(\"### Issue\"));\n            assertFalse(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(issue.id()));\n            assertTrue(pr.store().body().contains(issue.title()));\n            assertFalse(pr.store().body().contains(csr.id()));\n            assertFalse(pr.store().body().contains(csr.title()));\n\n            // Set the fix versions of the primary CSR to 17 and 18.\n            csr.setProperty(\"fixVersions\", JSON.array().add(\"17\").add(\"18\"));\n            // Run bot. The primary CSR has the fix version `17`, so it would be used.\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            // The bot should have primary issue and primary CSR\n            assertTrue(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(issue.id()));\n            assertTrue(pr.store().body().contains(issue.title()));\n            assertTrue(pr.store().body().contains(csr.id()));\n            assertTrue(pr.store().body().contains(csr.title() + \" (**CSR**)\"));\n\n            // Revert the fix versions of the primary CSR to 18.\n            csr.setProperty(\"fixVersions\", JSON.array().add(\"18\"));\n            // Create a backport issue whose fix version is 17\n            var backportIssue = issueProject.createIssue(\"This is the backport issue\", List.of(), Map.of());\n            backportIssue.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            backportIssue.setProperty(\"fixVersions\", JSON.array().add(\"17\"));\n            backportIssue.setState(Issue.State.OPEN);\n            issue.addLink(Link.create(backportIssue, \"backported by\").build());\n            // Run bot. The bot can find a backport issue but can't find a backport CSR.\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            // The bot should have primary issue and shouldn't have primary CSR.\n            assertTrue(pr.store().body().contains(\"### Issue\"));\n            assertFalse(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(issue.id()));\n            assertTrue(pr.store().body().contains(issue.title()));\n            assertFalse(pr.store().body().contains(csr.id()));\n            assertFalse(pr.store().body().contains(csr.title()));\n            assertFalse(pr.store().body().contains(backportIssue.id()));\n            assertFalse(pr.store().body().contains(backportIssue.title()));\n\n            // Create a backport CSR whose fix version is 17.\n            var backportCsr = issueProject.createIssue(\"This is the backport CSR\", List.of(), Map.of());\n            backportCsr.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            backportCsr.setProperty(\"fixVersions\", JSON.array().add(\"17\"));\n            backportCsr.setState(Issue.State.OPEN);\n            backportIssue.addLink(Link.create(backportCsr, \"csr for\").build());\n            // Run bot. The bot can find a backport issue and a backport CSR.\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            // The bot should have primary issue and backport CSR.\n            assertTrue(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(issue.id()));\n            assertTrue(pr.store().body().contains(issue.title()));\n            assertTrue(pr.store().body().contains(backportCsr.id()));\n            assertTrue(pr.store().body().contains(backportCsr.title() + \" (**CSR**)\"));\n            assertFalse(pr.store().body().contains(csr.id()));\n            assertFalse(pr.store().body().contains(csr.title()));\n            assertFalse(pr.store().body().contains(backportIssue.id()));\n            assertFalse(pr.store().body().contains(backportIssue.title()));\n\n            // Now we have a primary issue, a primary CSR, a backport issue, a backport CSR.\n            // Set the backport CSR to have multiple fix versions, included 11.\n            backportCsr.setProperty(\"fixVersions\", JSON.array().add(\"17\").add(\"11\").add(\"8\"));\n            // Set the `version` in `.jcheck/conf` as 11.\n            defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            newConf = defaultConf.replace(\"version=17\", \"version=11\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            confHash = localRepo.commit(\"Set the version as 11\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"edit\", true);\n            // Run bot.\n            TestBotRunner.runPeriodicItems(prBot);\n            // The PR should have primary issue and backport CSR.\n            assertTrue(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(issue.id()));\n            assertTrue(pr.store().body().contains(issue.title()));\n            assertTrue(pr.store().body().contains(backportCsr.id()));\n            assertTrue(pr.store().body().contains(backportCsr.title() + \" (**CSR**)\"));\n            assertFalse(pr.store().body().contains(csr.id()));\n            assertFalse(pr.store().body().contains(csr.title()));\n            assertFalse(pr.store().body().contains(backportIssue.id()));\n            assertFalse(pr.store().body().contains(backportIssue.title()));\n\n            // Set the backport CSR to have multiple fix versions, excluded 11.\n            backportCsr.setProperty(\"fixVersions\", JSON.array().add(\"17\").add(\"8\"));\n            // Run bot.\n            TestBotRunner.runPeriodicItems(csrIssueBot);\n            // The bot should have primary issue and shouldn't have CSR.\n            assertTrue(pr.store().body().contains(\"### Issue\"));\n            assertFalse(pr.store().body().contains(\"### Issues\"));\n            assertTrue(pr.store().body().contains(issue.id()));\n            assertTrue(pr.store().body().contains(issue.title()));\n            assertFalse(pr.store().body().contains(csr.id()));\n            assertFalse(pr.store().body().contains(csr.title()));\n            assertFalse(pr.store().body().contains(backportIssue.id()));\n            assertFalse(pr.store().body().contains(backportIssue.title()));\n            assertFalse(pr.store().body().contains(backportCsr.id()));\n            assertFalse(pr.store().body().contains(backportCsr.title()));\n        }\n    }\n\n    @Test\n    void testProblemListsIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issueProject)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(),\n                    Path.of(\"appendable.txt\"), Set.of(\"author\", \"reviewers\", \"whitespace\", \"problemlists\"), \"0.1\");\n\n            // Add problemlists configuration to conf\n            var checkConf = tempFolder.path().resolve(\".jcheck/conf\");\n            Files.writeString(checkConf, \"\\n[checks \\\"problemlists\\\"]\\n\", StandardOpenOption.APPEND);\n            Files.writeString(checkConf, \"dirs=test/jdk\\n\", StandardOpenOption.APPEND);\n            // Create ProblemList.txt\n            Files.createDirectories(tempFolder.path().resolve(\"test/jdk\"));\n            var problemList = tempFolder.path().resolve(\"test/jdk/ProblemList.txt\");\n            Files.writeString(problemList, \"test 1 windows-all\", StandardOpenOption.CREATE);\n            localRepo.add(tempFolder.path().resolve(\".jcheck/conf\"));\n            localRepo.add(problemList);\n            localRepo.commit(\"add problemList.txt\", \"testauthor\", \"ta@none.none\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"A line\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n\n            var issue = issueProject.createIssue(\"The main issue\", List.of(\"main\"), Map.of(\"issuetype\", JSON.of(\"Bug\")));\n\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id());\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().body().contains(\"1 is used in problem lists\"));\n        }\n    }\n\n    @Test\n    void missingJCheckConf(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n\n            // Remove .jcheck/conf\n            localRepo.remove(localRepo.root().resolve(\".jcheck/conf\"));\n            localRepo.commit(\"no conf\", \"testauthor\", \"ta@none.none\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Create a new branch\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertLastCommentContains(pr, \" ⚠️ @\" + pr.author().username() + \" No `.jcheck/conf` found in the target branch of this pull request. \"\n                    + \"Until that is resolved, this pull request cannot be processed. Please notify the repository owner.\");\n            // Make sure the warning message will be sent only once\n            TestBotRunner.runPeriodicItems(checkBot);\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertEquals(2, pr.comments().size());\n\n            // Restore .jcheck/conf\n            localRepo.checkout(masterHash);\n            Files.createDirectories(tempFolder.path().resolve(\".jcheck\"));\n            var checkConf = tempFolder.path().resolve(\".jcheck/conf\");\n            writeToCheckConf(checkConf);\n            localRepo.add(checkConf);\n            var restoreHash = localRepo.commit(\"add conf to master\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(restoreHash, author.authenticatedUrl(), \"master\", true);\n\n            pr.addComment(\".jcheck/conf is uploaded\");\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n        }\n    }\n\n    @Test\n    void invalidJCheckConf(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n\n            // Make .jcheck/conf invalid\n            var checkConf = tempFolder.path().resolve(\".jcheck/conf\");\n            Files.writeString(checkConf, \"\\nRandomCharacters\", StandardOpenOption.APPEND);\n            localRepo.add(checkConf);\n            var masterHash = localRepo.commit(\"make .jcheck/conf invalid\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Create a new branch\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertLastCommentContains(pr, \" ⚠️ @\" + pr.author().username() + \" The `.jcheck/conf` in the target branch of this pull request is invalid. \"\n                    + \"Until that is resolved, this pull request cannot be processed. Please notify the repository owner.\");\n            // Make sure the warning message will be sent only once\n            TestBotRunner.runPeriodicItems(checkBot);\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertEquals(2, pr.comments().size());\n\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            // Close the pr so we can skip CheckWorkItem\n            pr.setState(Issue.State.CLOSED);\n            reviewerPr.addComment(\"/reviewers 2\");\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertEquals(\"<!-- Jmerge command reply message (2) -->\\n\" +\n                    \"@user2 JCheck configuration is invalid in the target branch of this pull request. \" +\n                    \"Please issue this command again once the problem has been resolved.\", pr.comments().get(3).body());\n\n            pr.setTargetRef(\"notExist\");\n            reviewerPr.addComment(\"/reviewers 2\");\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertEquals(\"<!-- Jmerge command reply message (4) -->\\n\" +\n                    \"@user2 The target branch of this pull request no longer exists. \" +\n                    \"Please retarget this pull request. \" +\n                    \"Please issue this command again once the problem has been resolved.\", pr.comments().get(5).body());\n\n            pr.setTargetRef(\"master\");\n            pr.setState(Issue.State.OPEN);\n\n            // Restore .jcheck/conf\n            localRepo.checkout(masterHash);\n            Files.createDirectories(tempFolder.path().resolve(\".jcheck\"));\n            writeToCheckConf(checkConf);\n            localRepo.add(checkConf);\n            var restoreHash = localRepo.commit(\"restore conf\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(restoreHash, author.authenticatedUrl(), \"master\", true);\n\n            // Restore .jcheck/conf in source branch\n            localRepo.checkout(editHash);\n            Files.createDirectories(tempFolder.path().resolve(\".jcheck\"));\n            writeToCheckConf(checkConf);\n            localRepo.add(checkConf);\n            var restoreEditHash = localRepo.commit(\"restore source branch conf\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(restoreEditHash, author.authenticatedUrl(), \"edit\", true);\n\n            pr.addComment(\".jcheck/conf is uploaded\");\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n        }\n    }\n\n    @Test\n    void missingExternalJcheckConf(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var conf = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .confOverrideRepo(conf)\n                    .confOverrideName(\"jcheck.conf\")\n                    .confOverrideRef(\"jcheck-branch\")\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Remove conf\n            localRepo.remove(localRepo.root().resolve(\".jcheck/conf\"));\n            var newMasterHash = localRepo.commit(\"No more conf\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(newMasterHash, author.authenticatedUrl(), \"master\");\n\n            // Create a new branch\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertLastCommentContains(pr, \" ⚠️ @\" + pr.author().username() + \" The external jcheck configuration for this repository could not be found. \"\n                    + \"Until that is resolved, this pull request cannot be processed. Please notify a Skara admin.\");\n            // Make sure the warning message will be sent only once\n            TestBotRunner.runPeriodicItems(checkBot);\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertEquals(2, pr.comments().size());\n\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            // Close the pr so we can skip CheckWorkItem\n            pr.setState(Issue.State.CLOSED);\n            reviewerPr.addComment(\"/reviewers 2\");\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertEquals(\"<!-- Jmerge command reply message (2) -->\\n\" +\n                    \"@user2 The JCheck configuration has been overridden, but is missing. Skara admins have been notified. \" +\n                    \"Please issue this command again once the problem has been resolved.\", pr.comments().get(3).body());\n            pr.setState(Issue.State.OPEN);\n\n            // Upload .jcheck/conf to jcheck-branch\n            var jCheckBranch = localRepo.branch(masterHash, \"jcheck-branch\");\n            localRepo.checkout(jCheckBranch);\n            var checkConf = tempFolder.path().resolve(\"jcheck.conf\");\n            writeToCheckConf(checkConf);\n            localRepo.add(checkConf);\n            var restoreHash = localRepo.commit(\"restore conf\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(restoreHash, conf.authenticatedUrl(), \"jcheck-branch\", true);\n\n            pr.addComment(\"jcheck.conf is uploaded\");\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n        }\n    }\n\n    @Test\n    void invalidExternalJcheckConf(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var conf = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .confOverrideRepo(conf)\n                    .confOverrideName(\"jcheck.conf\")\n                    .confOverrideRef(\"jcheck-branch\")\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Remove conf\n            localRepo.remove(localRepo.root().resolve(\".jcheck/conf\"));\n            var newMasterHash = localRepo.commit(\"No more conf\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(newMasterHash, author.authenticatedUrl(), \"master\");\n\n            // Upload invalid jcheck.conf to conf repo\n            var jCheckBranch = localRepo.branch(masterHash, \"jcheck-branch\");\n            localRepo.checkout(jCheckBranch);\n            var checkConf = tempFolder.path().resolve(\"jcheck.conf\");\n            Files.writeString(checkConf, \"\\nRandomCharacters\", StandardOpenOption.CREATE);\n            localRepo.add(checkConf);\n            var confHash = localRepo.commit(\"restore conf\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(confHash, conf.authenticatedUrl(), \"jcheck-branch\", true);\n\n            // Create a new branch\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status (should become ready immediately as reviewercount is overridden to 0)\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertLastCommentContains(pr, \" ⚠️ @\" + pr.author().username() + \" The external jcheck configuration for this repository is invalid. \"\n                    + \"Until that is resolved, this pull request cannot be processed. Please notify a Skara admin.\");\n            // Make sure the warning message will be sent only once\n            TestBotRunner.runPeriodicItems(checkBot);\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertEquals(2, pr.comments().size());\n\n            // restore jcheck.conf to jcheck-branch\n            localRepo.checkout(jCheckBranch);\n            writeToCheckConf(checkConf);\n            localRepo.add(checkConf);\n            var restoreHash = localRepo.commit(\"restore conf\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(restoreHash, conf.authenticatedUrl(), \"jcheck-branch\", true);\n\n            pr.addComment(\"jcheck.conf is uploaded\");\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n        }\n    }\n\n    private void writeToCheckConf(Path checkConf) throws IOException {\n        try (var output = Files.newBufferedWriter(checkConf)) {\n            output.append(\"[general]\\n\");\n            output.append(\"project=test\\n\");\n            output.append(\"jbs=tstprj\\n\");\n            output.append(\"\\n\");\n            output.append(\"[checks]\\n\");\n            output.append(\"error=\");\n            output.append(String.join(\",\", Set.of(\"author\", \"reviewers\", \"whitespace\")));\n            output.append(\"\\n\");\n            output.append(\"warning=\");\n            output.append(String.join(\",\", Set.of(\"issuestitle\")));\n            output.append(\"\\n\\n\");\n            output.append(\"[census]\\n\");\n            output.append(\"version=0\\n\");\n            output.append(\"domain=openjdk.org\\n\");\n            output.append(\"\\n\");\n            output.append(\"[checks \\\"whitespace\\\"]\\n\");\n            output.append(\"files=.*\\\\.txt\\n\");\n            output.append(\"\\n\");\n            output.append(\"[checks \\\"reviewers\\\"]\\n\");\n            output.append(\"reviewers=1\\n\");\n        }\n    }\n\n    @Test\n    void testForcePush(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n            TestBotRunner.runPeriodicItems(checkBot);\n            pr.addComment(\"initial\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR shouldn't have the force-push suggestion comment\n            assertEquals(2, pr.comments().size());\n            var lastComment = pr.comments().getLast();\n            assertTrue(lastComment.body().contains(\"initial\"));\n            assertFalse(lastComment.body().contains(FORCE_PUSH_MARKER));\n            assertFalse(lastComment.body().contains(FORCE_PUSH_SUGGESTION));\n\n            // Normally push.\n            var updatedHash = CheckableRepository.appendAndCommit(localRepo, \"Normally push\");\n            localRepo.push(updatedHash, author.authenticatedUrl(), \"edit\", false);\n            pr.addComment(\"Normally push\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The PR shouldn't have the force-push suggestion comment.\n            assertEquals(3, pr.comments().size());\n            lastComment = pr.comments().getLast();\n            assertTrue(lastComment.body().contains(\"Normally push\"));\n            assertFalse(lastComment.body().contains(FORCE_PUSH_MARKER));\n            assertFalse(lastComment.body().contains(FORCE_PUSH_SUGGESTION));\n\n            // Simulate force-push.\n            updatedHash = CheckableRepository.appendAndCommit(localRepo, \"test force-push\");\n            localRepo.checkout(editHash);\n            localRepo.squash(updatedHash);\n            var forcePushHash = localRepo.commit(\"test force-push\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(forcePushHash, author.authenticatedUrl(), \"edit\", true);\n            pr.addComment(\"Force-push\");\n            pr.setLastForcePushTime(ZonedDateTime.now());\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The last comment of the PR should be the force-push suggestion comment.\n            assertEquals(5, pr.comments().size());\n            lastComment = pr.comments().getLast();\n            assertFalse(lastComment.body().contains(\"Force-push\"));\n            assertTrue(lastComment.body().contains(FORCE_PUSH_MARKER));\n            assertTrue(lastComment.body().contains(FORCE_PUSH_SUGGESTION));\n\n            // Convert pr to draft\n            pr.store().setDraft(true);\n\n            // Normally push again.\n            updatedHash = CheckableRepository.appendAndCommit(localRepo, \"Normally push\");\n            localRepo.push(updatedHash, author.authenticatedUrl(), \"edit\", false);\n            pr.addComment(\"Normally push in draft\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The last comment of the PR shouldn't be the force-push suggestion comment.\n            assertEquals(6, pr.comments().size());\n            lastComment = pr.comments().getLast();\n            assertTrue(lastComment.body().contains(\"Normally push in draft\"));\n            assertFalse(lastComment.body().contains(FORCE_PUSH_MARKER));\n            assertFalse(lastComment.body().contains(FORCE_PUSH_SUGGESTION));\n\n            // Simulate force-push in draft.\n            updatedHash = CheckableRepository.appendAndCommit(localRepo, \"test force-push in draft\");\n            localRepo.checkout(editHash);\n            localRepo.squash(updatedHash);\n            forcePushHash = localRepo.commit(\"test force-push in draft\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(forcePushHash, author.authenticatedUrl(), \"edit\", true);\n            pr.setLastForcePushTime(ZonedDateTime.now());\n            pr.addComment(\"Force-push in draft\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The last comment of the PR should not be the force-push suggestion comment.\n            assertEquals(7, pr.comments().size());\n            lastComment = pr.comments().getLast();\n            assertTrue(lastComment.body().contains(\"Force-push in draft\"));\n            assertFalse(lastComment.body().contains(FORCE_PUSH_MARKER));\n            assertFalse(lastComment.body().contains(FORCE_PUSH_SUGGESTION));\n\n            // Convert pr to ready\n            pr.store().setDraft(false);\n\n            // Nothing should happen\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertEquals(7, pr.comments().size());\n            lastComment = pr.comments().getLast();\n            assertTrue(lastComment.body().contains(\"Force-push in draft\"));\n            assertFalse(lastComment.body().contains(FORCE_PUSH_MARKER));\n            assertFalse(lastComment.body().contains(FORCE_PUSH_SUGGESTION));\n\n            // Force-push again\n            updatedHash = CheckableRepository.appendAndCommit(localRepo, \"force-push again\");\n            localRepo.checkout(editHash);\n            localRepo.squash(updatedHash);\n            forcePushHash = localRepo.commit(\"test force-push again\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(forcePushHash, author.authenticatedUrl(), \"edit\", true);\n            pr.setLastForcePushTime(ZonedDateTime.now());\n            pr.addComment(\"Force-push again\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // The last comment of the PR should be the force-push suggestion comment.\n            assertEquals(9, pr.comments().size());\n            lastComment = pr.comments().getLast();\n            assertFalse(lastComment.body().contains(\"Force-push\"));\n            assertTrue(lastComment.body().contains(FORCE_PUSH_MARKER));\n            assertTrue(lastComment.body().contains(FORCE_PUSH_SUGGESTION));\n        }\n    }\n\n    @Test\n    void testLatestBody(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            CheckWorkItem checkWorkItem = (CheckWorkItem) checkBot.getPeriodicItems().get(1);\n            checkWorkItem.pr = author.pullRequest(pr.id());\n            // Update PR body right now\n            pr.store().setBody(\"It's a new Body\");\n            try (var scratchFolder = new TemporaryDirectory()) {\n                checkWorkItem.prRun(new ScratchArea(scratchFolder.path(), checkBot.name()));\n            }\n            // PR body should not be updated by Bot\n            assertEquals(\"It's a new Body\", pr.store().body());\n\n            checkWorkItem = (CheckWorkItem) checkBot.getPeriodicItems().get(1);\n            checkWorkItem.pr = author.pullRequest(pr.id());\n            try (var scratchFolder = new TemporaryDirectory()) {\n                checkWorkItem.prRun(new ScratchArea(scratchFolder.path(), checkBot.name()));\n            }\n            // PR body should be updated by Bot\n            assertTrue(pr.store().body().contains(\"It's a new Body\"));\n            assertTrue(pr.store().body().contains(\"Progress\"));\n            assertTrue(pr.store().body().contains(\"<!-- Anything below this marker will be automatically updated\"));\n            assertTrue(pr.store().body().contains(\"Reviewing\"));\n        }\n    }\n\n    @Test\n    void testRunJcheckTwice(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            // set the .jcheck/conf without whitespace and issuestitle check\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"), Set.of(\"author\", \"reviewers\"), Set.of(), \"0.1\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var issue = issues.createIssue(\"This is an issue.\", List.of(\"Test\"), Map.of());\n            // Make a change with a corresponding PR, add a line with whitespace issue\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"An additional line\\r\\n\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id());\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check succeeded\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n            // pr body should not have the process for whitespace\n            assertFalse(pr.store().body().contains(\"whitespace\"));\n            assertFalse(pr.store().body().contains(\"Warning\"));\n\n            // Add whitespace and issuestitle check to .jcheck/conf\n            var checkConf = tempFolder.path().resolve(\".jcheck/conf\");\n            writeToCheckConf(checkConf);\n            localRepo.add(checkConf);\n            var updateHash = localRepo.commit(\"enable whitespace issue check\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(updateHash, author.authenticatedUrl(), \"edit\", true);\n\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // pr body should have the integrationBlocker for whitespace and reviewer check, also warning for issuestitle check\n            assertTrue(pr.store().body().contains(\"Whitespace errors (failed with updated jcheck configuration in pull request)\"));\n            assertTrue(pr.store().body().contains(\"Too few reviewers with at least role reviewer found (have 0, need at least 1) (failed with updated jcheck configuration in pull request)\"));\n            assertTrue(pr.store().body().contains(\"Found trailing period in issue title for `1: This is an issue.` (failed with updated jcheck configuration in pull request)\"));\n\n            var approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // // pr body should only have the integrationBlocker for whitespace check\n            assertTrue(pr.store().body().contains(\"Whitespace errors (failed with updated jcheck configuration in pull request)\"));\n            assertFalse(pr.store().body().contains(\"Too few reviewers with at least role reviewer found (have 0, need at least 1) (failed with updated jcheck configuration in pull request)\"));\n        }\n    }\n\n    @Test\n    void testNotRunJcheckTwice(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .build();\n\n            // Populate the projects repository\n            // set the .jcheck/conf without whitespace check\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"), Set.of(\"author\", \"reviewers\"), \"0.1\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR, add a line with whitespace issue\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"An additional line\\r\\n\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check succeeded\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n            // pr body should not have the process for whitespace\n            assertFalse(pr.store().body().contains(\"whitespace\"));\n\n            localRepo.checkout(masterHash);\n            // Add whitespace check to .jcheck/conf\n            var checkConf = tempFolder.path().resolve(\".jcheck/conf\");\n            writeToCheckConf(checkConf);\n            localRepo.add(checkConf);\n            var updateHash = localRepo.commit(\"enable whitespace issue check\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(updateHash, author.authenticatedUrl(), \"master\", true);\n            CheckableRepository.appendAndCommit(localRepo, \"An additional line1\\r\\n\");\n            CheckableRepository.appendAndCommit(localRepo, \"An additional line2\\r\\n\");\n            updateHash = CheckableRepository.appendAndCommit(localRepo, \"An additional line3\\r\\n\");\n            localRepo.push(updateHash, author.authenticatedUrl(), \"master\", true);\n\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // pr body should not have the integrationBlocker for whitespace and reviewer check\n            assertFalse(pr.store().body().contains(\"Whitespace errors (failed with updated jcheck configuration in pull request)\"));\n            assertFalse(pr.store().body().contains(\"Too few reviewers with at least role reviewer found (have 0, need at least 1) (failed with updated jcheck configuration in pull request)\"));\n        }\n    }\n\n    @Test\n    void testRunJcheckTwiceWithBadConfiguration(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var checkBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .build();\n\n            // Populate the projects repository\n            // set the .jcheck/conf without whitespace check\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"), Set.of(\"author\", \"reviewers\"), \"0.1\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR, add a line with whitespace issue\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"An additional line\\r\\n\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify that the check succeeded\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n            // pr body should not have the process for whitespace\n            assertFalse(pr.store().body().contains(\"whitespace\"));\n\n            // Make .jcheck/conf invalid\n            var checkConf = tempFolder.path().resolve(\".jcheck/conf\");\n            Files.writeString(checkConf, \"\\nRandomCharacters\", StandardOpenOption.APPEND);\n            localRepo.add(checkConf);\n            var updateHash = localRepo.commit(\"make .jcheck/conf invalid\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(updateHash, author.authenticatedUrl(), \"edit\", true);\n\n            assertThrows(RuntimeException.class, () -> TestBotRunner.runPeriodicItems(checkBot));\n\n            // Verify that the check failed\n            checks = pr.checks(updateHash);\n            assertEquals(1, checks.size());\n            check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.FAILURE, check.status());\n            assertEquals(\"line 27: entry must be of form 'key = value'\", check.summary().get());\n            assertEquals(\"Exception occurred during source jcheck - the operation will be retried\", check.title().get());\n\n            // Restore .jcheck/conf and add whitespace issue check\n            writeToCheckConf(checkConf);\n            localRepo.add(checkConf);\n            updateHash = localRepo.commit(\"enable whitespace issue check\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(updateHash, author.authenticatedUrl(), \"edit\", true);\n\n            TestBotRunner.runPeriodicItems(checkBot);\n            // pr body should have the integrationBlocker for whitespace and reviewer check\n            assertTrue(pr.store().body().contains(\"Whitespace errors (failed with updated jcheck configuration in pull request)\"));\n            assertTrue(pr.store().body().contains(\"Too few reviewers with at least role reviewer found (have 0, need at least 1) (failed with updated jcheck configuration in pull request)\"));\n        }\n    }\n\n    @Test\n    void testWebrevLinkinPRBody(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            reviewer.forge().currentUser().changeUserName(\"mlbridge[bot]\");\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .mlbridgeBotName(\"mlbridge[bot]\")\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(prBot);\n\n            var reviewPr = reviewer.pullRequest(pr.id());\n            reviewPr.addComment(\"comment1\");\n\n            // This one should not trigger update\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Add Webrev comment\n            reviewPr.addComment(WEBREV_COMMENT_MARKER + \"\\n\" + \"00:Full(1afrv2f)\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"Link to Webrev Comment\"));\n        }\n    }\n\n    @Test\n    void mergeDisabled(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .enableMerge(false)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge dev\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(prBot);\n\n            var comment = pr.comments().getLast();\n            assertEquals(2, pr.comments().size());\n            assertTrue(comment.body().contains(\"Merge-style pull requests are not allowed in this repository\"));\n\n            pr.setTitle(\"Merge test:dev\");\n            TestBotRunner.runPeriodicItems(prBot);\n            comment = pr.comments().getLast();\n            assertEquals(2, pr.comments().size());\n            assertTrue(comment.body().contains(\"Merge-style pull requests are not allowed in this repository\"));\n\n            pr.setTitle(\"SKARA-123\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(2, pr.comments().size());\n        }\n    }\n\n    @Test\n    void backportDisabled(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .enableBackport(false)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport 0123456789012345678901234567890123456789\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(prBot);\n\n            var comment = pr.comments().getLast();\n            assertEquals(2, pr.comments().size());\n            assertTrue(comment.body().contains(\"backports are not allowed in this repository\"));\n\n            pr.setTitle(\"Backport 123\");\n            TestBotRunner.runPeriodicItems(prBot);\n            comment = pr.comments().getLast();\n            assertEquals(2, pr.comments().size());\n            assertTrue(comment.body().contains(\"backports are not allowed in this repository\"));\n\n            pr.setTitle(\"SKARA-123\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(2, pr.comments().size());\n        }\n    }\n\n    @Test\n    void targetJCheckConfUpdate(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(prBot);\n\n            assertTrue(pr.store().body().contains(\"1 review required\"));\n\n            // Run it again\n            TestBotRunner.runPeriodicItems(prBot);\n\n            //Make a change to .jcheck/conf in target branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            var newConf = defaultConf.replace(\"reviewers=1\", \"reviewers=2\");\n            Files.writeString(localRepo.root().resolve(\".jcheck/conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\".jcheck/conf\"));\n            var confHash = localRepo.commit(\"set reviewers=2\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"master\", true);\n\n            TestBotRunner.runPeriodicItems(prBot);\n\n            assertTrue(pr.store().body().contains(\"2 reviews required\"));\n\n            // Run it again\n            TestBotRunner.runPeriodicItems(prBot);\n\n            TestBotRunner.runPeriodicItems(prBot);\n        }\n    }\n\n    @Test\n    void maintainerApprovalWithDependentPR(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue.setProperty(\"priority\", JSON.of(\"4\"));\n            var issue2 = issueProject.createIssue(\"This is an issue2\", List.of(), Map.of());\n            issue2.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue2.setProperty(\"priority\", JSON.of(\"4\"));\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            Approval approval = new Approval(\"\", \"-critical-request\", \"-critical-approved\",\n                    \"-critical-rejected\", \"https://example.com\", true, \"maintainer approval\");\n            approval.addBranchPrefix(Pattern.compile(\"jdk20.0.1\"), \"CPU23_04\");\n            approval.addBranchPrefix(Pattern.compile(\"jdk20.0.2\"), \"CPU23_05\");\n\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(issuePRMap)\n                    .approval(approval)\n                    .integrators(Set.of(reviewer.forge().currentUser().username()))\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"jdk20.0.1\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n\n            var pr = credentials.createPullRequest(author, \"jdk20.0.1\", \"edit\", issue.id() + \": This is an issue\");\n\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            localRepo.push(editHash, author.authenticatedUrl(), PreIntegrations.preIntegrateBranch(pr), true);\n\n            var followUp = CheckableRepository.appendAndCommit(localRepo, \"Follow-up work\", \"Follow-up change\");\n            localRepo.push(followUp, author.authenticatedUrl(), \"followup\", true);\n            var followUpPr = credentials.createPullRequest(author, PreIntegrations.preIntegrateBranch(pr), \"followup\", issue2.id());\n            TestBotRunner.runPeriodicItems(prBot);\n\n            assertTrue(followUpPr.store().body().contains(\"needs maintainer approval\"));\n        }\n    }\n\n    @Test\n    void overrideJcheckConfAndAdditionalConf(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var conf = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var checkBot = PullRequestBot.newBuilder()\n                                         .repo(author)\n                                         .censusRepo(censusBuilder.build())\n                                         .confOverrideRepo(conf)\n                                         .confOverrideName(\"jcheck.conf\")\n                                         .confOverrideRef(\"jcheck-branch\")\n                                         .reviewMerge(MergePullRequestReviewConfiguration.ALWAYS)\n                                         .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Create a different conf on a different branch\n            var defaultConf = Files.readString(localRepo.root().resolve(\".jcheck/conf\"));\n            var newConf = defaultConf.replace(\"reviewers=1\", \"reviewers=0\");\n            Files.writeString(localRepo.root().resolve(\"jcheck.conf\"), newConf);\n            localRepo.add(localRepo.root().resolve(\"jcheck.conf\"));\n            var confHash = localRepo.commit(\"Separate conf\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"jcheck-branch\", true);\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n\n            localRepo.checkout(masterHash, true);\n            localRepo.branch(masterHash, \"dev\");\n            localRepo.merge(editHash, Repository.FastForward.DISABLE);\n            var mergeHash = localRepo.commit(\"Merge edit\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"dev\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"dev\", \"Merge edit\");\n\n            // Check the status (should become ready immediately as reviewercount is overridden to 0)\n            // even though merge PRs should always be reviewed\n            TestBotRunner.runPeriodicItems(checkBot);\n            assertEquals(Set.of(\"rfr\", \"ready\", \"clean\"), new HashSet<>(pr.store().labelNames()));\n        }\n    }\n\n    @Test\n    void fixVersionNotMatch(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue.setProperty(\"priority\", JSON.of(\"4\"));\n            issue.setState(Issue.State.OPEN);\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"18\"));\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(issuePRMap)\n                    .versionMismatchWarning(true)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"1\");\n\n\n            // Populate the projects repository\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"(⚠️ The fixVersion in this issue is [18] but the fixVersion in .jcheck/conf is 0.1, a new backport will be created when this pr is integrated.)\"));\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"0.1\"));\n            pr.store().setBody(\"update\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().body().contains(\"(⚠️ The fixVersion\"));\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"0.1\").add(\"0.2\"));\n            pr.store().setBody(\"update\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"(⚠️ The fixVersion in this issue is [0.1, 0.2] but the fixVersion in .jcheck/conf is 0.1, a new backport will be created when this pr is integrated.)\"));\n\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"tbd\"));\n            pr.store().setBody(\"update\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().body().contains(\"(⚠️ The fixVersion\"));\n        }\n    }\n\n    @Test\n    void versionMismatchWarningOffByDefault(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue.setProperty(\"priority\", JSON.of(\"4\"));\n            issue.setState(Issue.State.OPEN);\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"18\"));\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"1\");\n\n\n            // Populate the projects repository\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().body().contains(\"(⚠️ The fixVersion\"));\n        }\n    }\n\n    @Test\n    void issuesTitleCheck(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"),Set.of(\"author\", \"reviewers\", \"whitespace\"), Set.of(\"issuestitle\"), \"0.1\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // An issue with trailing period\n            var issue1 = issues.createIssue(\"    This is an issue.   \", List.of(\"Hello\"), Map.of());\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue1.id(), List.of(\"Body\"), false);\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(prBot);\n\n            assertTrue(pr.store().body().contains(\"Warning\"));\n            assertTrue(pr.store().body().contains(\"Found trailing period in issue title for `1: This is an issue.`\"));\n\n            // Remove the trailing period in the title\n            pr.setTitle(\"1:     This is an issue\");\n            issue1.setTitle(\"    This is an issue\");\n\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().body().contains(\"Warning\"));\n            assertFalse(pr.store().body().contains(\"Found trailing period in issue title for 1: This is an issue.\"));\n\n            // Create another issue with trailing period\n            var issue2 = issues.createIssue(\"   this is an issue2 etc.    \", List.of(\"Hello\"), Map.of());\n            pr.addComment(\"/issue add \" + issue2.id());\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().body().contains(\"Found trailing period in issue title for `2: this is an issue2 etc.`\"));\n            assertTrue(pr.store().body().contains(\"Found leading lowercase letter in issue title for `2: this is an issue2 etc.`\"));\n\n            // Change the leading letter to upper case\n            issue2.setTitle(\"This is an issue2 etc.\");\n            pr.setBody(\"update this pr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            // The additional issue marker should be updated, so the warning of leading lowercase letter no longer exists\n            assertFalse(pr.store().body().contains(\"Found leading lowercase letter in issue title for `2: this is an issue2 etc.`\"));\n            assertFalse(pr.store().body().contains(\"Found trailing period in issue title for `2: This is an issue2 etc.`\"));\n\n            // Approve it as Reviewer, warnings shouldn't prevent adding ready label to the pr\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"LGTM\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n\n            // Should be able to integrate with warnings\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(\"integrated\"));\n        }\n    }\n\n    @Test\n    void copyrightCheck(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"),Set.of(\"author\", \"reviewers\", \"whitespace\"), Set.of(\"copyright\"), \"0.1\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"/*\\n\" +\n                    \" * Copyright (c) 2024,  Oracle and/or its affiliates. All rights reserved.\\n\" +\n                    \" * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\\n\" +\n                    \" */\\n\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Pull Request\", List.of(\"Body\"), false);\n            // Check the status\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"Found copyright format issue for oracle in [appendable.txt]\"));\n\n            // Fix the issue\n            var editHash2 = CheckableRepository.replaceAndCommit(localRepo, \"/*\\n\" +\n                    \" * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.\\n\" +\n                    \" * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\\n\" +\n                    \" */\\n\");\n            localRepo.push(editHash2, author.authenticatedUrl(), \"edit\", true);\n            // Check the status\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().body().contains(\"Found copyright format issue for oracle in [appendable.txt]\"));\n\n            // Replace the oracle copyright with red hat one\n            var editHash3 = CheckableRepository.replaceAndCommit(localRepo, \"/*\\n\" +\n                    \" * Copyright (c) 2024,  Red Hat, Inc.\\n\" +\n                    \" * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\\n\" +\n                    \" */\\n\");\n            localRepo.push(editHash3, author.authenticatedUrl(), \"edit\", true);\n            // Check the status\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(\"Found copyright format issue for redhat in [appendable.txt]\"));\n            assertTrue(pr.store().body().contains(\"Can't find copyright header for oracle in [appendable.txt]\"));\n        }\n    }\n\n    @Test\n    void WhitespaceAndReviewersCheckAsWarnings(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), Set.of(\"reviewers\", \"whitespace\"), \"0.1\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            var issue1 = issues.createIssue(\"This is an issue\", List.of(\"Hello\"), Map.of());\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"An additional line\\r\\n\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue1.id(), List.of(\"Body\"), false);\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(prBot);\n\n            assertFalse(pr.store().body().contains(\"Warning\"));\n        }\n    }\n\n    @Test\n    void onlyStripTrailingWhitespaceInPRBody(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(issuePRMap)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"), Set.of(), Set.of(\"reviewers\", \"whitespace\"), \"0.1\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            var issue1 = issues.createIssue(\"This is an issue\", List.of(\"Hello\"), Map.of());\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"An additional line\\r\\n\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue1.id(),\n                    List.of(\"\\t\", \" \", \"First non-whitespace line\", \"\\t\", \" \")\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Only trailing whitespace should have been stripped\n            var body = author.pullRequest(pr.id()).body();\n            var expectedBodyPrefix =\n                \"\\t\\n\" +\n                \" \\n\" +\n                \"First non-whitespace line\\n\" +\n                \"\\n\" +\n                PROGRESS_MARKER;\n            assertTrue(body.startsWith(expectedBodyPrefix), body);\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/CleanCommandTests.java",
    "content": "/*\n * Copyright (c) 2021, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.util.HashMap;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\npublic class CleanCommandTests {\n    @Test\n    void cleanCommandOnRegularPullRequestShouldNotWork(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            assertFalse(pr.store().labelNames().contains(\"backport\"));\n            assertFalse(pr.store().labelNames().contains(\"clean\"));\n\n            // Try to issue the \"/clean\" PR command, should not work\n            pr.addComment(\"/clean\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(\"backport\"));\n            assertFalse(pr.store().labelNames().contains(\"clean\"));\n            assertLastCommentContains(pr, \"Can only mark [backport pull requests]\");\n            assertLastCommentContains(pr, \", with an original hash, as clean\");\n        }\n    }\n\n    @Test\n    void alreadyCleanPullRequest(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory(false)) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var comments = pr.comments();\n            var backportComment = comments.get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n\n            // The bot should have added the \"clean\" label\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n\n            // Issue the \"/clean\" PR command, should do nothing\n            pr.addComment(\"/clean\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n            assertLastCommentContains(pr, \"This backport pull request is already marked as clean\");\n        }\n    }\n\n    @Test\n    void makeNonCleanBackportClean(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory(false)) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var masterHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\\ne\");\n            localRepo.add(newFile);\n            var issue2 = credentials.createIssue(issues, \"Another issue\");\n            var issue2Number = issue2.id().split(\"-\")[1];\n            var upstreamMessage = issue2Number + \": Another issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var upstreamHash = localRepo.commit(upstreamMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(upstreamHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\\nd\");\n            localRepo.add(newFile);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + upstreamHash.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var comments = pr.comments();\n            var backportComment = comments.get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + upstreamHash.hex() + \" -->\"));\n            assertEquals(issue2Number + \": Another issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n\n            // The bot should not have added the \"clean\" label\n            assertFalse(pr.store().labelNames().contains(\"clean\"));\n\n            // Use the \"/clean\" pull request command to mark the backport PR as clean\n            pr.addComment(\"/clean\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertTrue(pr.store().labelNames().contains(\"clean\"), \"PR not marked clean\");\n            assertTrue(pr.comments().stream()\n                    .anyMatch(c -> c.body().contains(\"This backport pull request is now marked as clean\")));\n            assertTrue(pr.store().labelNames().contains(\"ready\"), \"PR not marked ready\");\n        }\n    }\n\n    @Test\n    void authorShouldNotBeAllowed(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory(false)) {\n\n            var author = credentials.getHostedRepository();\n            var contributor = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(contributor.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var masterHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\\ne\");\n            localRepo.add(newFile);\n            var issue2 = credentials.createIssue(issues, \"Another issue\");\n            var issue2Number = issue2.id().split(\"-\")[1];\n            var upstreamMessage = issue2Number + \": Another issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var upstreamHash = localRepo.commit(upstreamMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(upstreamHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\\nd\");\n            localRepo.add(newFile);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + upstreamHash.hex());\n\n            // The bot should reply with a backport message\n            TestBotRunner.runPeriodicItems(bot);\n            var comments = pr.comments();\n            var backportComment = comments.get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + upstreamHash.hex() + \" -->\"));\n            assertEquals(issue2Number + \": Another issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n\n            // The bot should not have added the \"clean\" label\n            assertFalse(pr.store().labelNames().contains(\"clean\"));\n\n            // Use the \"/clean\" pull request command as author, should not work\n            var prAsAuthor = contributor.pullRequest(pr.id());\n            prAsAuthor.addComment(\"/clean\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertFalse(pr.store().labelNames().contains(\"clean\"));\n            assertLastCommentContains(pr, \"Only OpenJDK [Committers]\");\n            assertLastCommentContains(pr, \"can use the `/clean` command\");\n        }\n    }\n\n    @Test\n    void missingBackportHash(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id());\n            var issues = credentials.getIssueProject();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var issue = credentials.createIssue(issues, \"An issue\");\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + issue.id());\n            TestBotRunner.runPeriodicItems(prBot);\n\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n            assertFalse(pr.store().labelNames().contains(\"clean\"));\n\n            // Try to issue the \"/clean\" PR command, should not work\n            pr.addComment(\"/clean\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n            assertFalse(pr.store().labelNames().contains(\"clean\"));\n            assertLastCommentContains(pr, \"Can only mark [backport pull requests]\");\n            assertLastCommentContains(pr, \", with an original hash, as clean\");\n        }\n    }\n\n    @Test\n    void cleanCommandDisabled(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory(false)) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .cleanCommandEnabled(false)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                    \"\\n\" +\n                    \"Reviewed-by: integrationreviewer2\";\n            var masterHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\\ne\");\n            localRepo.add(newFile);\n            var issue2 = credentials.createIssue(issues, \"Another issue\");\n            var issue2Number = issue2.id().split(\"-\")[1];\n            var upstreamMessage = issue2Number + \": Another issue\\n\" +\n                    \"\\n\" +\n                    \"Reviewed-by: integrationreviewer2\";\n            var upstreamHash = localRepo.commit(upstreamMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(upstreamHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            Files.writeString(newFile, \"a\\nb\\nc\\nd\\nd\");\n            localRepo.add(newFile);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + upstreamHash.hex());\n\n            TestBotRunner.runPeriodicItems(bot);\n\n            // The bot should not have added the \"clean\" label\n            assertFalse(pr.store().labelNames().contains(\"clean\"));\n\n            // Use the \"/clean\" pull request command to mark the backport PR as clean\n            pr.addComment(\"/clean\");\n            TestBotRunner.runPeriodicItems(bot);\n            // The pr shouldn't have clean label since clean command is disabled\n            assertFalse(pr.store().labelNames().contains(\"clean\"));\n            assertLastCommentContains(pr, \"The `/clean` pull request command is not enabled for this repository\");\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/CommitCommandAsserts.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.CommitComment;\n\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\npublic class CommitCommandAsserts {\n    public static void assertLastCommentContains(List<CommitComment> comments, String contains) {\n        assertTrue(!comments.isEmpty());\n        var lastComment = comments.getLast();\n        assertTrue(lastComment.body().contains(contains), lastComment.body());\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/CommitCommandTests.java",
    "content": "/*\n * Copyright (c) 2020, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\npublic class CommitCommandTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .processPR(false)\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change directly on master\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"master\");\n\n            // Add a help command\n            author.addCommitComment(editHash, \"/help\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Look at the reply\n            var replies = author.commitComments(editHash);\n            CommitCommandAsserts.assertLastCommentContains(replies, \"Available commands\");\n            CommitCommandAsserts.assertLastCommentContains(replies, \"Commit Commands documentation\");\n\n            // Add a command which is only valid in pull request\n            author.addCommitComment(editHash, \"/issue 12\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            replies = author.commitComments(editHash);\n            CommitCommandAsserts.assertLastCommentContains(replies, \"The command `issue` can only be used in pull requests.\");\n\n            // Try an invalid one\n            author.addCommitComment(editHash, \"/hello\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            replies = author.commitComments(editHash);\n            CommitCommandAsserts.assertLastCommentContains(replies, \"Unknown command `hello` - for a list of valid commands use `/help`.\");\n\n            editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // The command would not be processed\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/help\");\n            TestBotRunner.runPeriodicItems(bot);\n            PullRequestAsserts.assertLastCommentContains(pr, \"/help\");\n        }\n    }\n\n    @Test\n    void simplePullRequest(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var botRepo = credentials.getHostedRepository();\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(botRepo)\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(\"jdk17u-dev\", credentials.getHostedRepository()))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Add a `backport` command\n            pr.addComment(\"/backport jdk17u-dev\");\n            TestBotRunner.runPeriodicItems(bot);\n            // The `backport` command is invalid because the pull request is not integrated.\n            PullRequestAsserts.assertLastCommentContains(pr, \"Backport for repo `jdk17u-dev` on branch `master` was successfully enabled\");\n\n            // Simulate an integration\n            var botPr = botRepo.pullRequest(pr.id());\n            localRepo.push(editHash, author.authenticatedUrl(), \"master\");\n            botPr.addComment(\"Pushed as commit \" + editHash.hex() + \".\");\n            botPr.addLabel(\"integrated\");\n            botPr.setState(Issue.State.CLOSED);\n\n            // Add a help command\n            pr.addComment(\"/help\");\n            TestBotRunner.runPeriodicItems(bot);\n            PullRequestAsserts.assertLastCommentContains(pr, \"Available commands\");\n\n            // Add a `backport` command\n            pr.addComment(\"/backport jdk17u-dev\");\n            TestBotRunner.runPeriodicItems(bot);\n            // The `backport` command is valid.\n            PullRequestAsserts.assertLastCommentContains(pr, \"Could **not** automatically backport\");\n            PullRequestAsserts.assertLastCommentContains(pr, \"Please fetch the appropriate branch/commit and manually resolve these conflicts\");\n\n            // Try an unavailable one\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(bot);\n            PullRequestAsserts.assertLastCommentContains(pr, \"can only be used in open pull requests\");\n        }\n    }\n\n    @Test\n    void commitNotItRepository(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change directly on master\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"master\");\n\n            // Make a commit only present in pr branch\n            var prHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(prHash, author.authenticatedUrl(), \"pr/1\", true);\n\n            // Add a help command to commit in pr branch\n            var comment = author.addCommitComment(prHash, \"/help\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Verify that the bot did *not* reply\n            assertEquals(List.of(comment), author.commitComments(prHash));\n        }\n    }\n\n    @Test\n    void externalCommitCommand(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .externalCommitCommands(Map.of(\"external\", \"Help for external command\"))\n                                    .seedStorage(seedFolder)\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change directly on master\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"master\");\n\n            // Add a help command\n            author.addCommitComment(editHash, \"/help\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Look at the reply\n            var replies = author.commitComments(editHash);\n            CommitCommandAsserts.assertLastCommentContains(replies, \"Available commands\");\n            CommitCommandAsserts.assertLastCommentContains(replies, \"external\");\n            CommitCommandAsserts.assertLastCommentContains(replies, \"Help for external command\");\n        }\n    }\n\n    @Test\n    void missingJcheckConf(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .externalCommitCommands(Map.of(\"external\", \"Help for external command\"))\n                                    .seedStorage(seedFolder)\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = Repository.init(tempFolder.path(), author.repositoryType());\n            var readme = localRepo.root().resolve(\"README\");\n            Files.writeString(readme, \"Hello, world!\");\n            localRepo.add(readme);\n            var masterHash = localRepo.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add a help command\n            author.addCommitComment(masterHash, \"/help\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Look at the reply\n            var replies = author.commitComments(masterHash);\n            CommitCommandAsserts.assertLastCommentContains(replies, \"There is no `.jcheck/conf` present at revision\");\n            CommitCommandAsserts.assertLastCommentContains(replies, \"cannot process command\");\n        }\n    }\n\n    @Test\n    void disableProcessCommit(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .processCommit(false)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change directly on master\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"master\");\n\n            // Add a help command\n            author.addCommitComment(editHash, \"/help\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // The command should not be processed\n            var replies = author.commitComments(editHash);\n            CommitCommandAsserts.assertLastCommentContains(replies, \"/help\");\n\n            editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // The pr command would be processed\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/help\");\n            TestBotRunner.runPeriodicItems(bot);\n            PullRequestAsserts.assertLastCommentContains(pr, \"Available commands:\");\n            PullRequestAsserts.assertLastCommentContains(pr, \"Pull Request Commands documentation\");\n        }\n    }\n\n    @Test\n    void disableProcessCommitAndProcessPR(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                    .seedStorage(seedFolder)\n                    .processCommit(false)\n                    .processPR(false)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change directly on master\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"master\");\n\n            // Add a help command\n            author.addCommitComment(editHash, \"/help\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            // The command should not be processed\n            var replies = author.commitComments(editHash);\n            CommitCommandAsserts.assertLastCommentContains(replies, \"/help\");\n\n            editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // The pr command would not be processed\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/help\");\n            TestBotRunner.runPeriodicItems(bot);\n            PullRequestAsserts.assertLastCommentContains(pr, \"/help\");\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/ContributorTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.Review;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.Repository;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.StandardOpenOption;\nimport java.util.ArrayList;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\nclass ContributorTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue an invalid command\n            pr.addComment(\"/contributor hello\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr,\"Syntax\");\n\n            // Add a contributor\n            pr.addComment(\"/contributor add Test Person <test@test.test>\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"successfully added\");\n\n            // Remove it again\n            pr.addComment(\"/contributor remove Test Person <test@test.test>\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"successfully removed\");\n\n            // Remove something that isn't there\n            pr.addComment(\"/contributor remove Test Person <test@test.test>\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr,\"There are no additional contributors associated with this pull request\");\n\n            // Now add someone back again\n            pr.addComment(\"/contributor add Test Person <test@test.test>\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The commit message preview should contain the contributor\n            var creditLine = pr.comments().stream()\n                               .flatMap(comment -> comment.body().lines())\n                               .filter(line -> line.contains(\"Test Person <test@test.test>\"))\n                               .filter(line -> line.contains(\"Co-authored-by\"))\n                               .findAny()\n                               .orElseThrow();\n            assertEquals(\"Co-authored-by: Test Person <test@test.test>\", creditLine);\n\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"change now passes all *automated*\"))\n                           .count();\n            assertEquals(1, pushed);\n\n            // Add a second person\n            pr.addComment(\"/contributor add Another Person <another@test.test>\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            creditLine = pr.comments().stream()\n                           .flatMap(comment -> comment.body().lines())\n                           .filter(line -> line.contains(\"Another Person <another@test.test>\"))\n                           .filter(line -> line.contains(\"Co-authored-by\"))\n                           .findAny()\n                           .orElseThrow();\n            assertEquals(\"Co-authored-by: Another Person <another@test.test>\", creditLine);\n\n            // Integrate\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an ok message\n            assertLastCommentContains(pr,\"Pushed as commit\");\n\n            // The change should now be present on the master branch\n            var pushedFolder = tempFolder.path().resolve(\"pushed\");\n            var pushedRepo = Repository.materialize(pushedFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n\n            // The contributor should be credited\n            creditLine = headCommit.message().stream()\n                    .filter(line -> line.contains(\"Test Person <test@test.test>\"))\n                    .findAny()\n                    .orElseThrow();\n            assertEquals(\"Co-authored-by: Test Person <test@test.test>\", creditLine);\n        }\n    }\n\n    @Test\n    void invalidCommandAuthor(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var external = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue a contributor command not as the PR author\n            var externalPr = external.pullRequest(pr.id());\n            externalPr.addComment(\"/contributor add Test Person <test@test.test>\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"Only the author\"))\n                          .count();\n            assertEquals(1, error);\n        }\n    }\n\n    @Test\n    void invalidContributor(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Use an invalid full name\n            pr.addComment(\"/contributor add Moo <Foo.Bar (at) host.com>\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"`Moo <Foo.Bar (at) host.com>` was not found in the census.\");\n\n            // Empty platform id\n            pr.addComment(\"/contributor add @\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"`@` is not a valid user in this repository.\");\n\n            // Unknown platform id\n            pr.addComment(\"/contributor add @someone\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"`@someone` is not a valid user in this repository.\");\n\n            // Unknown openjdk user\n            pr.addComment(\"/contributor add someone\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"`someone` was not found in the census.\");\n\n            // No full name\n            pr.addComment(\"/contributor add some@one\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"`some@one` is not a valid name and email string.\");\n        }\n    }\n\n    @Test\n    void platformUser(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Use a platform name\n            pr.addComment(\"/contributor add @\" + author.forge().currentUser().username());\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply\n            assertLastCommentContains(pr, \"Contributor `Generated Committer 2 <integrationcommitter2@openjdk.org>` successfully added\");\n        }\n    }\n\n    @Test\n    void openJdkUser(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Use a platform name\n            pr.addComment(\"/contributor add integrationreviewer1\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply\n            assertLastCommentContains(pr, \"Contributor `Generated Reviewer 1 <integrationreviewer1@openjdk.org>` successfully added\");\n        }\n    }\n\n    @Test\n    void removeContributor(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Remove a contributor that hasn't been added\n            pr.addComment(\"/contributor remove Foo Bar <foo.bar@host.com>\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"There are no additional contributors associated with this pull request.\");\n\n            // Add a contributor\n            pr.addComment(\"/contributor add Foo Bar <foo.bar@host.com>\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"successfully added.\");\n\n            // Remove another (not added) contributor\n            pr.addComment(\"/contributor remove Baz Bar <baz.bar@host.com>\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Contributor `Baz Bar <baz.bar@host.com>` was not found.\");\n            assertLastCommentContains(pr, \"Current additional contributors are:\");\n            assertLastCommentContains(pr, \"- `Foo Bar <foo.bar@host.com>`\");\n\n            // Remove an existing contributor\n            pr.addComment(\"/contributor remove Foo Bar <foo.bar@host.com>\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"successfully removed.\");\n        }\n    }\n\n    @Test\n    void prBodyUpdates(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Add a contributor\n            pr.addComment(\"/contributor add Foo Bar <foo.bar@host.com>\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"successfully added.\");\n\n            // Verify that body is updated\n            var body = pr.store().body().split(\"\\n\");\n            var contributorsHeaderIndex = -1;\n            for (var i = 0; i < body.length; i++) {\n                var line = body[i];\n                if (line.equals(\"### Contributors\")) {\n                    contributorsHeaderIndex = i;\n                    break;\n                }\n            }\n            assertNotEquals(contributorsHeaderIndex, -1);\n            var contributors = new ArrayList<String>();\n            for (var i = contributorsHeaderIndex + 1; i < body.length && body[i].startsWith(\" * \"); i++) {\n                contributors.add(body[i].substring(3));\n            }\n            assertEquals(1, contributors.size());\n            assertEquals(\"Foo Bar `<foo.bar@host.com>`\", contributors.get(0));\n\n            // Remove contributor\n            pr.addComment(\"/contributor remove Foo Bar <foo.bar@host.com>\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"successfully removed.\");\n\n            // Verify that body does not contain \"Contributors\" section\n            for (var line : pr.store().body().split(\"\\n\")) {\n                assertNotEquals(\"### Contributors\", line);\n            }\n        }\n    }\n\n    @Test\n    void testDomain(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var checkConf = localRepoFolder.resolve(\".jcheck/conf\");\n            Files.writeString(checkConf, \"[census]\\n\" +\n                    \"version=0\\n\" +\n                    \"domain=test.com\", StandardOpenOption.APPEND);\n            localRepo.add(checkConf);\n            localRepo.commit(\"modify .jcheck/conf\", \"testauthor\", \"ta@none.none\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue an invalid command\n            pr.addComment(\"/contributor hello\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr, \"Syntax\");\n\n            // Add a contributor\n            pr.addComment(\"/contributor add integrationcommitter2\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr, \"successfully added\");\n            assertLastCommentContains(pr, \"<integrationcommitter2@test.com>\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The commit message preview should contain the contributor\n            var creditLine = pr.comments().stream()\n                    .flatMap(comment -> comment.body().lines())\n                    .filter(line -> line.contains(\"Generated Committer 2 <integrationcommitter2@test.com>\"))\n                    .filter(line -> line.contains(\"Co-authored-by\"))\n                    .findAny()\n                    .orElseThrow();\n            assertEquals(\"Co-authored-by: Generated Committer 2 <integrationcommitter2@test.com>\", creditLine);\n\n            var pushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"change now passes all *automated*\"))\n                    .count();\n            assertEquals(1, pushed);\n\n            // Integrate\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an ok message\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            // The change should now be present on the master branch\n            var pushedFolder = tempFolder.path().resolve(\"pushed\");\n            var pushedRepo = Repository.materialize(pushedFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n\n            // The contributor should be credited\n            creditLine = headCommit.message().stream()\n                    .filter(line -> line.contains(\"Generated Committer 2 <integrationcommitter2@test.com>\"))\n                    .findAny()\n                    .orElseThrow();\n            assertEquals(\"Co-authored-by: Generated Committer 2 <integrationcommitter2@test.com>\", creditLine);\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/IntegrateTests.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\nclass IntegrateTests {\n    @Test\n    void simpleMerge(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var pushedFolder = new TemporaryDirectory()) {\n\n            var botUser = credentials.getHostedRepository();\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(botUser).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // The bot should reply with integration message\n            TestBotRunner.runPeriodicItems(mergeBot);\n            var integrateComments = pr.comments()\n                                      .stream()\n                                      .filter(c -> c.body().contains(\"To integrate this PR with the above commit message to the `master` branch\"))\n                                      .filter(c -> c.body().contains(\"If you prefer to avoid any potential automatic rebasing\"))\n                                      .count();\n            assertEquals(1, integrateComments);\n\n            // Attempt a merge (the bot should only process the first one)\n            pr.addComment(\"/integrate\");\n            pr.addComment(\"/integrate\");\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed);\n\n            // The change should now be present on the master branch\n            var pushedRepo = Repository.materialize(pushedFolder.path(), author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n\n            // Author and committer should be the same\n            assertEquals(\"Generated Committer 1\", headCommit.author().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.author().email());\n            assertEquals(\"Generated Committer 1\", headCommit.committer().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.committer().email());\n            assertTrue(pr.store().labelNames().contains(\"integrated\"));\n\n            // Ready label should have been removed\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n        }\n    }\n\n    @Test\n    void reviewersRetained(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var pushedFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var committer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addCommitter(committer.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Review it twice\n            var integratorPr = integrator.pullRequest(pr.id());\n            var committerPr = committer.pullRequest(pr.id());\n            integratorPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            committerPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Attempt a merge\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed);\n\n            // The change should now be present on the master branch\n            var pushedRepo = Repository.materialize(pushedFolder.path(), author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n            var headCommit = pushedRepo.commits(\"HEAD\").asList().get(0);\n            assertTrue(String.join(\"\", headCommit.message())\n                             .matches(\".*Reviewed-by: integrationreviewer3, integrationcommitter2$\"),\n                       String.join(\"\", headCommit.message()));\n        }\n    }\n\n    @Test\n    void notChecked(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Attempt a merge, but point the check at some other commit\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot, item -> {\n                if (item instanceof CheckWorkItem) {\n                    var newCheck = CheckBuilder.create(\"jcheck\", masterHash).build();\n                    pr.updateCheck(newCheck);\n                }\n            });\n\n            // The bot should reply with an error message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"integration request cannot be fulfilled at this time\"))\n                          .filter(comment -> comment.body().contains(\"status check\"))\n                          .filter(comment -> comment.body().contains(\"has not been performed on commit\"))\n                          .count();\n            assertEquals(1, error);\n        }\n    }\n\n    @Test\n    void notReviewed(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository - but without any checks enabled\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"),\n                                                     Set.of(), null);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Now enable checks\n            localRepo.checkout(masterHash, true);\n            CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"),\n                                     Set.of(\"author\", \"reviewers\", \"whitespace\"), null);\n            var updatedHash = localRepo.resolve(\"HEAD\").orElseThrow();\n            localRepo.push(updatedHash, author.authenticatedUrl(), \"master\", true);\n\n            // Attempt a merge\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr, \"pull request has not yet been marked as ready for integration\");\n        }\n    }\n\n    @Test\n    void failedCheck(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"trailing whitespace   \");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Attempt a merge\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"integration request cannot be fulfilled at this time\"))\n                          .filter(comment -> comment.body().contains(\"status check\"))\n                          .filter(comment -> comment.body().contains(\"did not complete successfully\"))\n                          .count();\n            assertEquals(1, error);\n        }\n    }\n\n    @Test\n    void outdatedCheck(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Flag it as checked\n            var check = CheckBuilder.create(\"testcheck\", editHash);\n            pr.createCheck(check.build());\n            check.complete(true);\n            pr.updateCheck(check.build());\n\n            // Now push another change\n            var updatedHash = CheckableRepository.appendAndCommit(localRepo, \"Yet another line\");\n            localRepo.push(updatedHash, author.authenticatedUrl(), \"edit\", true);\n\n            // Attempt a merge, but point the check at some other commit\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot, item -> {\n                if (item instanceof CheckWorkItem) {\n                    var newCheck = CheckBuilder.create(\"jcheck\", masterHash).build();\n                    pr.updateCheck(newCheck);\n                }\n            });\n\n            // The bot should reply with an error message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"integration request cannot be fulfilled at this time\"))\n                          .filter(comment -> comment.body().contains(\"status check\"))\n                          .filter(comment -> comment.body().contains(\"has not been performed on commit\"))\n                          .count();\n            assertEquals(1, error);\n        }\n    }\n\n    @Test\n    void mergeNotification(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot see it (a few times)\n            TestBotRunner.runPeriodicItems(mergeBot);\n            TestBotRunner.runPeriodicItems(mergeBot);\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an instructional message (and only one)\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"change now passes all *automated*\"))\n                           .filter(comment -> comment.body().contains(\"Reviewed-by: integrationreviewer3\"))\n                           .count();\n            assertEquals(1, pushed);\n\n            // Ensure that the bot doesn't pick up on commands in the instructional message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"Only the author\"))\n                          .count();\n            assertEquals(0, error);\n\n            // Drop the approval\n            approvalPr.addReview(Review.Verdict.DISAPPROVED, \"Disapproved\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The instructional message should have been updated\n            pushed = pr.comments().stream()\n                       .filter(comment -> comment.body().contains(\"no longer ready for integration\"))\n                       .count();\n            assertEquals(1, pushed);\n\n            // Restore the approval\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The instructional message should have been updated\n            pushed = pr.comments().stream()\n                       .filter(comment -> comment.body().contains(\"change now passes all *automated*\"))\n                       .filter(comment -> comment.body().contains(\"Reviewed-by: integrationreviewer3\"))\n                       .count();\n            assertEquals(1, pushed);\n\n            // Approve it as yet another user\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The instructional message should have been updated\n            pushed = pr.comments().stream()\n                       .filter(comment -> comment.body().contains(\"change now passes all *automated*\"))\n                       .filter(comment -> comment.body().contains(\"Reviewed-by: integrationreviewer3, integrationreviewer2\"))\n                       .count();\n            assertEquals(1, pushed);\n        }\n    }\n\n    @Test\n    void invalidCommandAuthor(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var external = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue a merge command not as the PR author\n            var externalPr = external.pullRequest(pr.id());\n            externalPr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"Only the author\"))\n                          .count();\n            assertEquals(1, error);\n        }\n    }\n\n    @Test\n    void invalidCommandSponsor(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var external = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(external.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Mark it as ready for integration\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Issue a merge command not as the PR author\n            var externalPr = external.pullRequest(pr.id());\n            externalPr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"Only the author\"))\n                          .filter(comment -> comment.body().contains(\"did you mean to\"))\n                          .filter(comment -> comment.body().contains(\"`/sponsor`\"))\n                          .count();\n            assertEquals(1, error);\n        }\n    }\n\n    @Test\n    void autoRebase(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var pushedFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Push something unrelated to master\n            localRepo.checkout(masterHash, true);\n            var unrelated = localRepo.root().resolve(\"unrelated.txt\");\n            Files.writeString(unrelated, \"Hello\");\n            localRepo.add(unrelated);\n            var unrelatedHash = localRepo.commit(\"Unrelated\", \"X\", \"x@y.z\");\n            localRepo.push(unrelatedHash, author.authenticatedUrl(), \"master\");\n\n            // Attempt a merge (the bot should only process the first one)\n            pr.addComment(\"/integrate\");\n            pr.addComment(\"/integrate\");\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var prePush = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Going to push as commit\"))\n                           .filter(comment -> comment.body().contains(\"commit was automatically rebased without conflicts\"))\n                           .count();\n            assertEquals(1, prePush);\n            var pushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .count();\n            assertEquals(1, pushed);\n\n            // The change should now be present on the master branch\n            var pushedRepo = Repository.materialize(pushedFolder.path(), author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n        }\n    }\n\n    @Test\n    void retryOnFailure(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var censusFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var censusRepo = censusBuilder.build();\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusRepo).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check it\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Break the census to cause an exception\n            var localCensus = Repository.materialize(censusFolder.path(), censusRepo.authenticatedUrl(), \"+master:current_census\");\n            var currentCensusHash = localCensus.resolve(\"current_census\").orElseThrow();\n            Files.writeString(censusFolder.path().resolve(\"contributors.xml\"), \"This is not xml\");\n            localCensus.add(censusFolder.path().resolve(\"contributors.xml\"));\n            var badCensusHash = localCensus.commit(\"Bad census update\", \"duke\", \"duke@openjdk.org\");\n            localCensus.push(badCensusHash, censusRepo.authenticatedUrl(), \"master\", true);\n\n            // Attempt a merge\n            pr.addComment(\"/integrate\");\n            assertThrows(RuntimeException.class, () -> TestBotRunner.runPeriodicItems(mergeBot));\n\n            // Restore the census\n            localCensus.push(currentCensusHash, censusRepo.authenticatedUrl(), \"master\", true);\n\n            // The bot should now retry\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed);\n        }\n    }\n\n    @Test\n    void cannotRebase(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Push something conflicting to master\n            localRepo.checkout(masterHash, true);\n            var conflictingHash = CheckableRepository.appendAndCommit(localRepo, \"This looks like a conflict\");\n            localRepo.push(conflictingHash, author.authenticatedUrl(), \"master\");\n\n            // Trigger a new check run\n            pr.setBody(pr.body() + \" recheck\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr, \"this pull request can not be integrated\");\n\n            // Attempt an integration\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertLastCommentContains(pr, \"pull request has not yet been marked as ready for integration\");\n        }\n    }\n\n    @Test\n    void noAutoRebase(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var pushedFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Push something unrelated to master\n            localRepo.checkout(masterHash, true);\n            var unrelated = localRepo.root().resolve(\"unrelated.txt\");\n            Files.writeString(unrelated, \"Hello\");\n            localRepo.add(unrelated);\n            var unrelatedHash = localRepo.commit(\"Unrelated\", \"X\", \"x@y.z\");\n            localRepo.push(unrelatedHash, author.authenticatedUrl(), \"master\");\n\n            // Attempt a merge\n            pr.addComment(\"/integrate \" + masterHash);\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr, \"the target branch is no longer at the requested hash\");\n\n            // Now use the correct target hash\n            pr.addComment(\"/integrate \" + unrelatedHash);\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            // The change should now be present on the master branch\n            var pushedRepo = Repository.materialize(pushedFolder.path(), author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n        }\n    }\n\n    @Test\n    void missingContributingFile(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an instructional message and no link to CONTRIBUTING.md\n            var lastComment = pr.comments().getLast();\n            assertFalse(lastComment.body().contains(\"CONTRIBUTING.md\"));\n        }\n    }\n\n    @Test\n    void existingContributingFile(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var contributingFile = localRepo.root().resolve(\"CONTRIBUTING.md\");\n            Files.writeString(contributingFile, \"Patches welcome!\\n\");\n            localRepo.add(contributingFile);\n            localRepo.commit(\"Add CONTRIBUTING.md\", \"duke\", \"duke@openjdk.org\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an instructional message and no link to CONTRIBUTING.md\n            var lastComment = pr.comments().getLast();\n            assertTrue(lastComment.body().contains(\"CONTRIBUTING.md\"));\n        }\n    }\n\n    @Test\n    void contributorMissingEmail(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var committer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(committer.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(reviewer).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with an empty e-mail\n            var authorFullName = author.forge().currentUser().fullName();\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Content\", \"A commit\", authorFullName, \"\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Run the bot\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should respond with a failure about missing e-mail\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            var checks = pr.checks(pr.headHash());\n            assertTrue(checks.containsKey(\"jcheck\"));\n            var jcheck = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.FAILURE, jcheck.status());\n            assertTrue(jcheck.summary().isPresent());\n            var summary = jcheck.summary().get();\n            assertTrue(summary.contains(\"Pull request's HEAD commit must contain a valid e-mail\"));\n        }\n    }\n\n    @Test\n    void invalidHash(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // The bot should reply with integration message\n            TestBotRunner.runPeriodicItems(mergeBot);\n            var integrateComments = pr.comments()\n                                      .stream()\n                                      .filter(c -> c.body().contains(\"To integrate this PR with the above commit message to the `master` branch\"))\n                                      .filter(c -> c.body().contains(\"If you prefer to avoid any potential automatic rebasing\"))\n                                      .count();\n            assertEquals(1, integrateComments);\n\n            // Attempt a merge (the bot should only process the first one)\n            pr.addComment(\"/integrate a3987asdf\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"is not a valid hash\"))\n                           .count();\n            assertEquals(1, pushed);\n\n            // Ready label should remain\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n        }\n    }\n\n    @Test\n    void integrateAutoInBody(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var pushedFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with auto integration\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"PR Title\", List.of(\"/integrate auto\"));\n\n            // The bot should add the auto label and reply\n            TestBotRunner.runPeriodicItems(mergeBot);\n            var integrateComments = pr.comments()\n                                      .stream()\n                                      .filter(c -> c.body().contains(\"This pull request will be automatically integrated\"))\n                                      .count();\n            assertEquals(1, integrateComments);\n            assertTrue(pr.store().labelNames().contains(\"auto\"));\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // The bot needs two rounds, first mark the PR as ready\n            TestBotRunner.runPeriodicItems(mergeBot);\n            // Then post the /integrate command and push\n            TestBotRunner.runPeriodicItems(mergeBot);\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed);\n\n            // The change should now be present on the master branch\n            var pushedRepo = Repository.materialize(pushedFolder.path(), author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n\n            // Author and committer should be the same\n            assertEquals(\"Generated Committer 1\", headCommit.author().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.author().email());\n            assertEquals(\"Generated Committer 1\", headCommit.committer().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.committer().email());\n            assertTrue(pr.store().labelNames().contains(\"integrated\"));\n\n            // Ready label should have been removed\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n        }\n    }\n\n    @Test\n    void integrateAutoInComment(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var pushedFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with auto integration\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"PR Title\");\n\n            // The bot should add the auto label and reply\n            pr.addComment(\"/integrate auto\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            var integrateComments = pr.comments()\n                                      .stream()\n                                      .filter(c -> c.body().contains(\"This pull request will be automatically integrated\"))\n                                      .count();\n            assertEquals(1, integrateComments);\n            assertTrue(pr.store().labelNames().contains(\"auto\"));\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // The bot needs two rounds, first mark the PR as ready\n            TestBotRunner.runPeriodicItems(mergeBot);\n            // Then post the /integrate command and push\n            TestBotRunner.runPeriodicItems(mergeBot);\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed);\n\n            // The change should now be present on the master branch\n            var pushedRepo = Repository.materialize(pushedFolder.path(), author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n\n            // Author and committer should be the same\n            assertEquals(\"Generated Committer 1\", headCommit.author().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.author().email());\n            assertEquals(\"Generated Committer 1\", headCommit.committer().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.committer().email());\n            assertTrue(pr.store().labelNames().contains(\"integrated\"));\n\n            // Ready label should have been removed\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n        }\n    }\n\n    @Test\n    void manualIntegration(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var pushedFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with auto integration\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"PR Title\", List.of(\"/integrate auto\"));\n\n            // The bot should add the auto label and reply\n            TestBotRunner.runPeriodicItems(mergeBot);\n            var integrateComments = pr.comments()\n                                      .stream()\n                                      .filter(c -> c.body().contains(\"This pull request will be automatically integrated\"))\n                                      .count();\n            assertEquals(1, integrateComments);\n            assertTrue(pr.store().labelNames().contains(\"auto\"));\n\n            // Make a comment to integrate manually\n            pr.addComment(\"/integrate manual\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with that the PR will have to be manually integrated\n            TestBotRunner.runPeriodicItems(mergeBot);\n            var replies = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"This pull request will have to be integrated manually\"))\n                           .count();\n            assertEquals(1, replies);\n\n            // The \"auto\" label should have been removed\n            assertFalse(pr.store().labelNames().contains(\"auto\"));\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // The bot should reply with integration message\n            TestBotRunner.runPeriodicItems(mergeBot);\n            integrateComments = pr.comments()\n                                  .stream()\n                                  .filter(c -> c.body().contains(\"To integrate this PR with the above commit message to the `master` branch\"))\n                                  .filter(c -> c.body().contains(\"If you prefer to avoid any potential automatic rebasing\"))\n                                  .count();\n            assertEquals(1, integrateComments);\n\n            // Issue the /integrate command\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed);\n\n            // The change should now be present on the master branch\n            var pushedRepo = Repository.materialize(pushedFolder.path(), author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n\n            // Author and committer should be the same\n            assertEquals(\"Generated Committer 1\", headCommit.author().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.author().email());\n            assertEquals(\"Generated Committer 1\", headCommit.committer().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.committer().email());\n            assertTrue(pr.store().labelNames().contains(\"integrated\"));\n\n            // Ready label should have been removed\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n        }\n    }\n\n    /**\n     * Tests recovery after successfully pushing the commit, but failing to update the PR\n     */\n    @Test\n    void retryAfterInterrupt(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id());\n            var censusRepo = censusBuilder.build();\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusRepo).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check it\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Attempt a merge\n            pr.addComment(\"/integrate\");\n\n            // Let it integrate\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Simulate that interruption occurred after prePush comment was added,\n            // but before any labels were changed\n            String commitCommentBody = \"Pushed as commit\";\n            var commitComment = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(commitCommentBody))\n                    .findAny().orElseThrow();\n            pr.removeComment(commitComment);\n            pr.addLabel(\"rfr\");\n            pr.addLabel(\"ready\");\n            pr.setState(Issue.State.OPEN);\n            pr.removeLabel(\"integrated\");\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n            // The bot should now retry\n            TestBotRunner.runPeriodicItems(mergeBot);\n            // The bot should reply with an ok message\n            retryAfterInterruptVerifyIntegrated(pr);\n\n            // Simulate that interruption occurred right after the integrated label was\n            // added\n            var commitComment2 = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(commitCommentBody))\n                    .findAny().orElseThrow();\n            pr.removeComment(commitComment2);\n            pr.addLabel(\"rfr\");\n            pr.addLabel(\"ready\");\n            pr.setState(Issue.State.OPEN);\n            // The bot should now retry\n            TestBotRunner.runPeriodicItems(mergeBot);\n            // The bot should reply with an ok message\n            retryAfterInterruptVerifyIntegrated(pr);\n\n            // Simulate that interruption occurred right after the PR was closed\n            var commitComment3 = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(commitCommentBody))\n                    .findAny().orElseThrow();\n            pr.removeComment(commitComment3);\n            pr.addLabel(\"rfr\");\n            pr.addLabel(\"ready\");\n            // The bot should now retry\n            TestBotRunner.runPeriodicItems(mergeBot);\n            // The bot should reply with an ok message\n            retryAfterInterruptVerifyIntegrated(pr);\n\n            // Simulate that interruption occurred right after the ready label was removed\n            var commitComment4 = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(commitCommentBody))\n                    .findAny().orElseThrow();\n            pr.removeComment(commitComment4);\n            pr.addLabel(\"rfr\");\n            // The bot should now retry\n            TestBotRunner.runPeriodicItems(mergeBot);\n            // The bot should reply with an ok message\n            retryAfterInterruptVerifyIntegrated(pr);\n\n            // Simulate that interruption happened just before the commit comment was added\n            var commitComment5 = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(commitCommentBody))\n                    .findAny().orElseThrow();\n            pr.removeComment(commitComment5);\n            // The bot should now retry\n            TestBotRunner.runPeriodicItems(mergeBot);\n            // The bot should reply with an ok message\n            retryAfterInterruptVerifyIntegrated(pr);\n\n            // Add another command and verify that no further action is taken\n            pr.addComment(\"/integrate\");\n\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            assertTrue(pr.comments().getLast().body()\n                    .contains(\"can only be used in open pull requests\"));\n        }\n    }\n\n    private void retryAfterInterruptVerifyIntegrated(TestPullRequest pr) throws IOException {\n        var pushed = pr.comments().stream()\n                .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                .count();\n        assertEquals(1, pushed, \"Commit comment not found\");\n        assertFalse(pr.store().labelNames().contains(\"ready\"), \"ready label not removed\");\n        assertFalse(pr.store().labelNames().contains(\"rfr\"), \"rfr label not removed\");\n        assertTrue(pr.store().labelNames().contains(\"integrated\"), \"integrated label not added\");\n    }\n\n    /**\n     * Tests recovery after successfully pushing the commit, but failing to update the PR,\n     * and an extra commit has been integrated to the target before retrying.\n     */\n    @Test\n    void retryAfterInterruptExtraChange(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id());\n            var censusRepo = censusBuilder.build();\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusRepo).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check it\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Attempt a merge\n            pr.addComment(\"/integrate\");\n\n            // Let it integrate\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Remove some labels and the commit comment to simulate that last attempt was interrupted\n            pr.removeLabel(\"integrated\");\n            pr.addLabel(\"ready\");\n            pr.addLabel(\"rfr\");\n            var commitComment = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .findAny().orElseThrow();\n            pr.removeComment(commitComment);\n\n            // Add a new commit to master branch\n            localRepo.checkout(new Branch(\"master\"));\n            localRepo.fetch(author.authenticatedUrl(), \"master\").orElseThrow();\n            localRepo.merge(new Branch(\"FETCH_HEAD\"));\n            var integratedHash = localRepo.resolve(\"master\");\n            var newMasterHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\",\n                    \"New master commit\");\n            localRepo.push(newMasterHash, author.authenticatedUrl(), \"master\", true);\n\n            // The bot should now retry\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var pushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit \" + integratedHash.orElseThrow()))\n                    .count();\n            assertEquals(1, pushed, \"Commit comment not found\");\n            assertFalse(pr.store().labelNames().contains(\"ready\"), \"ready label not removed\");\n            assertFalse(pr.store().labelNames().contains(\"rfr\"), \"rfr label not removed\");\n            assertTrue(pr.store().labelNames().contains(\"integrated\"), \"integrated label not added\");\n        }\n    }\n\n    @Test\n    void delegate(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var pushedFolder = new TemporaryDirectory()) {\n\n            var botUser = credentials.getHostedRepository();\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var badIntegrator = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addAuthor(badIntegrator.forge().currentUser().id())\n                    .addCommitter(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(botUser).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var authorPr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var reviewerPr = reviewer.pullRequest(authorPr.id());\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            // Issue /integrate defer command and verify deprecated message is printed and the PR gets delegated\n            authorPr.addComment(\"/integrate defer\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            var deferred = authorPr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Integration of this pull request has been delegated\"))\n                    .count();\n            var deprecated = authorPr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"`/integrate defer` is deprecated\"))\n                    .count();\n            assertEquals(1, deferred, \"Missing delegated message\");\n            assertEquals(1, deprecated, \"Missing deprecated message\");\n            assertTrue(authorPr.store().labelNames().contains(\"delegated\"));\n\n            // Issue /integrate undefer and verify deprecated message is printed the PR is no longer delegated\n            authorPr.addComment(\"/integrate undefer\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            var undeferred = authorPr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Integration of this pull request is no longer delegated and may only be integrated by the author\"))\n                    .count();\n            deprecated = authorPr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"`/integrate undefer` is deprecated\"))\n                    .count();\n            assertEquals(1, undeferred, \"Missing undelegated message\");\n            assertEquals(1, deprecated, \"Missing deprecated message\");\n            assertFalse(authorPr.store().labelNames().contains(\"delegated\"));\n\n\n            // Issue /integrate delegate command and verify the PR gets delegated\n            authorPr.addComment(\"/integrate delegate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            var delegated = authorPr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Integration of this pull request has been delegated\"))\n                    .count();\n            assertEquals(2, delegated, \"Missing delegated message\");\n            assertTrue(authorPr.store().labelNames().contains(\"delegated\"));\n\n            // Try to integrate by non committer\n            var badIntegratorPr = badIntegrator.pullRequest(authorPr.id());\n            badIntegratorPr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            var onlyCommitters = authorPr.comments().stream()\n                    .filter(comment -> comment.body()\n                            .contains(\"Only project committers are allowed to issue the `integrate` command on a delegated pull request.\"))\n                    .count();\n            assertEquals(1, onlyCommitters, \"Missing error about only committers can integrate\");\n\n            // Issue /integrate undelegate and verify the PR is no longer delegated\n            authorPr.addComment(\"/integrate undelegate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            var undelegated = authorPr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Integration of this pull request is no longer delegated and may only be integrated by the author\"))\n                    .count();\n            assertEquals(2, undelegated, \"Missing undelegated message\");\n            assertFalse(authorPr.store().labelNames().contains(\"delegated\"));\n\n            // Try integrating as another committer, which should fail since the PR is currently not delegated\n            var integratorPr = integrator.pullRequest(authorPr.id());\n            integratorPr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            var nonAuthor = authorPr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Only the author\")\n                            && comment.body().contains(\"is allowed to issue the `integrate` command\"))\n                    .count();\n            assertEquals(1, nonAuthor, \"Missing only author can integrate message\");\n\n            // Delegate again\n            authorPr.addComment(\"/integrate delegate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertTrue(authorPr.store().labelNames().contains(\"delegated\"));\n\n            // Try to issue /integrate with an invalid command for a non author\n            integratorPr.addComment(\"/integrate auto\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            var invalid = authorPr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Only the author\"))\n                    .count();\n            assertEquals(2, invalid, \"Missing error message\");\n\n            // Try to integrate by committer\n            integratorPr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The change should now be present on the master branch\n            var pushedRepo = Repository.materialize(pushedFolder.path(), author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n\n            // Verify that the author and committer of the change are the correct users\n            // The number is implied from the order the add* methods of CensusBuilder were called above.\n            assertEquals(\"Generated Committer 1\", headCommit.author().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.author().email());\n            assertEquals(\"Generated Committer 4\", headCommit.committer().name());\n            assertEquals(\"integrationcommitter4@openjdk.org\", headCommit.committer().email());\n            assertTrue(authorPr.store().labelNames().contains(\"integrated\"));\n\n            // Ready and delegated labels should have been removed\n            assertFalse(authorPr.store().labelNames().contains(\"ready\"));\n            assertFalse(authorPr.store().labelNames().contains(\"delegated\"));\n        }\n    }\n\n    /**\n     * When an author types the command `/integrate`, the label `sponsor` should be added.\n     * If the author becomes a committer and types the command `/integrate` again,\n     * the label `sponsor` should be removed which is similar to the labels `rfr` and `ready`.\n     */\n    @Test\n    void sponsor(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var pushedFolder = new TemporaryDirectory()) {\n\n            var botUser = credentials.getHostedRepository();\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var authorBot = PullRequestBot.newBuilder().repo(botUser).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var authorPr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var reviewerPr = reviewer.pullRequest(authorPr.id());\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Issue an integrate command without being a Committer\n            authorPr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(authorBot);\n\n            // The bot should reply that a sponsor is required\n            var sponsor = authorPr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"sponsor\"))\n                    .filter(comment -> comment.body().contains(\"your change\"))\n                    .count();\n            assertEquals(1, sponsor);\n            assertFalse(authorPr.store().labelNames().contains(\"integrated\"));\n            assertTrue(authorPr.store().labelNames().contains(\"sponsor\"));\n            assertTrue(authorPr.store().labelNames().contains(\"rfr\"));\n            assertTrue(authorPr.store().labelNames().contains(\"ready\"));\n\n            // The bot should not have pushed the commit\n            var notPushed = authorPr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .count();\n            assertEquals(0, notPushed);\n\n            // Mark the PR author a committer\n            var committerCensusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var committerBot = PullRequestBot.newBuilder().repo(botUser).censusRepo(committerCensusBuilder.build()).build();\n\n            // Issue an integrate command while being a Committer\n            authorPr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(committerBot);\n\n            // The bot should have pushed the commit\n            var pushed = authorPr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .count();\n            assertEquals(1, pushed);\n\n            // The corresponding labels should have been adjusted\n            assertTrue(authorPr.store().labelNames().contains(\"integrated\"));\n            assertFalse(authorPr.store().labelNames().contains(\"sponsor\"));\n            assertFalse(authorPr.store().labelNames().contains(\"rfr\"));\n            assertFalse(authorPr.store().labelNames().contains(\"ready\"));\n\n            // The change should now be present on the master branch\n            var pushedRepo = Repository.materialize(pushedFolder.path(), author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n\n            // Verify that the author and committer of the change are the correct users\n            assertEquals(\"Generated Committer 1\", headCommit.author().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.author().email());\n            assertEquals(\"Generated Committer 1\", headCommit.committer().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.committer().email());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/IntegrationLockTests.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.time.Duration;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class IntegrationLockTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            var l1 = IntegrationLock.create(pr, Duration.ofSeconds(10));\n            assertTrue(l1.isLocked());\n\n            var l2 = IntegrationLock.create(pr, Duration.ofMillis(100));\n            assertFalse(l2.isLocked());\n\n            l1.close();\n            var l3 = IntegrationLock.create(pr, Duration.ofSeconds(10));\n            assertTrue(l3.isLocked());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/IssueBotTests.java",
    "content": "/*\n * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\nimport org.openjdk.skara.forge.CheckStatus;\nimport org.openjdk.skara.forge.Review;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.test.CheckableRepository;\nimport org.openjdk.skara.test.HostCredentials;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestBotRunner;\n\n\nimport java.io.IOException;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.regex.Pattern;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n\npublic class IssueBotTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(issuePRMap)\n                    .build();\n            var issueBot = new IssueBot(issueProject, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \" : This is an issue\");\n\n            TestBotRunner.runPeriodicItems(prBot);\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            var completedTime1 = check.completedAt().get();\n            assertEquals(CheckStatus.SUCCESS, check.status());\n            var substrings = check.metadata().get().split(\"#\");\n            var prMetadata1 = substrings[0];\n            var issueMetadata1 = (substrings.length > 1) ? substrings[1] : \"\";\n            assertNotEquals(\"\", issueMetadata1);\n\n            // Run issueBot, there is no update in the issue, so the metadata should not change\n            TestBotRunner.runPeriodicItems(issueBot);\n            check = pr.checks(editHash).get(\"jcheck\");\n            var completedTime2 = check.completedAt().get();\n            assertEquals(completedTime1, completedTime2);\n\n            // Update the issue and run prBot first\n            // The check should not be updated\n            issue.setProperty(\"priority\", JSON.of(\"4\"));\n            TestBotRunner.runPeriodicItems(prBot);\n            check = pr.checks(editHash).get(\"jcheck\");\n            var completedTime3 = check.completedAt().get();\n            assertEquals(completedTime2, completedTime3);\n\n            // Run issueBot\n            // The check should be updated\n            TestBotRunner.runPeriodicItems(issueBot);\n            check = pr.checks(editHash).get(\"jcheck\");\n            var completedTime4 = check.completedAt().get();\n            substrings = check.metadata().get().split(\"#\");\n            var prMetadata2 = substrings[0];\n            var issueMetadata2 = (substrings.length > 1) ? substrings[1] : \"\";\n            assertNotEquals(completedTime3, completedTime4);\n            // PR body has been updated, so the metadata for pr is also changed\n            assertNotEquals(prMetadata1, prMetadata2);\n            assertNotEquals(issueMetadata1, issueMetadata2);\n            assertTrue(pr.store().body().contains(\"(**Bug** - P4)\"));\n\n            // Update the PR and run issueBot first\n            // There should be no update in the check\n            pr.setBody(\"updated body\");\n            TestBotRunner.runPeriodicItems(issueBot);\n            check = pr.checks(editHash).get(\"jcheck\");\n            var completedTime5 = check.completedAt().get();\n            assertEquals(completedTime4, completedTime5);\n\n            // Run prBot\n            TestBotRunner.runPeriodicItems(prBot);\n            check = pr.checks(editHash).get(\"jcheck\");\n            var completedTime6 = check.completedAt().get();\n            substrings = check.metadata().get().split(\"#\");\n            var prMetadata3 = substrings[0];\n            var issueMetadata3 = (substrings.length > 1) ? substrings[1] : \"\";\n            assertNotEquals(completedTime5, completedTime6);\n            assertNotEquals(prMetadata2, prMetadata3);\n            // issue metadata should not be updated because no update in the issue\n            assertEquals(issueMetadata2, issueMetadata3);\n\n            // Update issue title and run prBot first\n            // There should be no update in the check\n            issue.setTitle(\"This is an Issue\");\n            TestBotRunner.runPeriodicItems(prBot);\n            check = pr.checks(editHash).get(\"jcheck\");\n            var completedTime7 = check.completedAt().get();\n            assertEquals(completedTime6, completedTime7);\n\n            // Run issueBot\n            TestBotRunner.runPeriodicItems(issueBot);\n            check = pr.checks(editHash).get(\"jcheck\");\n            var completedTime8 = check.completedAt().get();\n            assertNotEquals(completedTime7, completedTime8);\n            assertEquals(\"1: This is an Issue\", pr.store().title());\n\n            // Extra run of prBot and issueBot\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(issueBot);\n            check = pr.checks(editHash).get(\"jcheck\");\n            var completedTime9 = check.completedAt().get();\n            assertEquals(completedTime8, completedTime9);\n        }\n    }\n\n    @Test\n    void normalCommentInIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(issuePRMap)\n                    .build();\n            var issueBot = new IssueBot(issueProject, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n\n            TestBotRunner.runPeriodicItems(prBot);\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            var completedTime1 = check.completedAt().get();\n            assertEquals(CheckStatus.SUCCESS, check.status());\n            var substrings = check.metadata().get().split(\"#\");\n            var prMetadata1 = substrings[0];\n            var issueMetadata1 = (substrings.length > 1) ? substrings[1] : \"\";\n            assertNotEquals(\"\", issueMetadata1);\n\n            // Add a normal comment in the issue\n            issue.addComment(\"The issue commment!\");\n            TestBotRunner.runPeriodicItems(issueBot);\n            check = pr.checks(editHash).get(\"jcheck\");\n            var completedTime2 = check.completedAt().get();\n            assertEquals(completedTime1, completedTime2);\n\n            // Extra run of prBot and issueBot\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(issueBot);\n            check = pr.checks(editHash).get(\"jcheck\");\n            var completedTime3 = check.completedAt().get();\n            assertEquals(completedTime2, completedTime3);\n        }\n    }\n\n    @Test\n    void multipleIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            var issue2 = issueProject.createIssue(\"This is an issue2\", List.of(), Map.of());\n            issue2.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(issuePRMap)\n                    .build();\n            var issueBot = new IssueBot(issueProject, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n            pr.addComment(\"/issue \" + issue2.id());\n\n            TestBotRunner.runPeriodicItems(prBot);\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            var completedTime1 = check.completedAt().get();\n            assertEquals(CheckStatus.SUCCESS, check.status());\n            var substrings = check.metadata().get().split(\"#\");\n            var prMetadata1 = substrings[0];\n            var issueMetadata1 = (substrings.length > 1) ? substrings[1] : \"\";\n            assertNotEquals(\"\", issueMetadata1);\n\n            // Run issueBot, check should not be updated\n            TestBotRunner.runPeriodicItems(issueBot);\n            check = pr.checks(editHash).get(\"jcheck\");\n            var completedTime2 = check.completedAt().get();\n            assertEquals(completedTime1, completedTime2);\n\n            // Update issue2\n            issue2.setProperty(\"priority\", JSON.of(\"4\"));\n            // Run prBot first, check should not be updated\n            TestBotRunner.runPeriodicItems(prBot);\n            check = pr.checks(editHash).get(\"jcheck\");\n            var completedTime3 = check.completedAt().get();\n            assertEquals(completedTime2, completedTime3);\n\n            // Run issueBot, check should be updated\n            TestBotRunner.runPeriodicItems(issueBot);\n            check = pr.checks(editHash).get(\"jcheck\");\n            var completedTime4 = check.completedAt().get();\n            assertNotEquals(completedTime3, completedTime4);\n            substrings = check.metadata().get().split(\"#\");\n            var prMetadata2 = substrings[0];\n            var issueMetadata2 = (substrings.length > 1) ? substrings[1] : \"\";\n            assertNotEquals(prMetadata1, prMetadata2);\n            assertNotEquals(issueMetadata1, issueMetadata2);\n            assertTrue(pr.store().body().contains(\"This is an issue (**Bug** - P3)\"));\n            assertTrue(pr.store().body().contains(\"This is an issue2 (**Bug** - P4)\"));\n\n            // Update issue\n            issue.setProperty(\"priority\", JSON.of(\"1\"));\n            // Run prBot first\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().body().contains(\"This is an issue (**Bug** - P1)\"));\n            // Run issueBot\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertTrue(pr.store().body().contains(\"This is an issue (**Bug** - P1)\"));\n            assertTrue(pr.store().body().contains(\"This is an issue2 (**Bug** - P4)\"));\n        }\n    }\n\n    @Test\n    void maintainerApproval(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue.setProperty(\"priority\", JSON.of(\"4\"));\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(issuePRMap)\n                    .approval(new Approval(\"\", \"jdk17u-fix-request\", \"jdk17u-fix-yes\",\n                            \"jdk17u-fix-no\", \"https://example.com\", true, \"maintainer approval\"))\n                    .build();\n            var issueBot = new IssueBot(issueProject, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n\n            issue.addLabel(\"jdk17u-fix-request\");\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertTrue(pr.store().body().contains(\"Requested\"));\n\n            issue.addLabel(\"jdk17u-fix-yes\");\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertTrue(pr.store().body().contains(\"Approved\"));\n        }\n    }\n\n    @Test\n    void maintainerApprovalWithBranchPattern(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(), Map.of());\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue.setProperty(\"priority\", JSON.of(\"4\"));\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            Approval approval = new Approval(\"\", \"-critical-request\", \"-critical-approved\",\n                    \"-critical-rejected\", \"https://example.com\", true, \"critical request\");\n            approval.addBranchPrefix(Pattern.compile(\"jdk20.0.1\"), \"CPU23_04\");\n            approval.addBranchPrefix(Pattern.compile(\"jdk20.0.2\"), \"CPU23_05\");\n\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(issuePRMap)\n                    .approval(approval)\n                    .build();\n            var issueBot = new IssueBot(issueProject, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var otherHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(otherHash, author.authenticatedUrl(), \"jdk20.0.1\", true);\n\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue.id() + \": This is an issue\");\n\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n            assertFalse(pr.store().body().contains(\"[TEST-1](http://localhost/project/testTEST-1) needs critical request\"));\n\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"Looks good\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n\n            pr.setTargetRef(\"jdk20.0.1\");\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"Looks good\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().body().contains(\"[TEST-1](http://localhost/project/testTEST-1) needs critical request\"));\n            assertEquals(\"⚠️  @user1 This change is now ready for you to apply for [critical request](https://example.com). \" +\n                            \"This can be done directly in each associated issue or by using the \" +\n                            \"[/approval](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands#PullRequestCommands-/approval) command.\" +\n                            \"<!-- PullRequestBot approval needed comment -->\"\n                    , pr.store().comments().get(2).body());\n\n            issue.addLabel(\"CPU23_04-critical-request\");\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertTrue(pr.store().body().contains(\"Requested\"));\n\n            issue.addLabel(\"CPU23_04-critical-approved\");\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertTrue(pr.store().body().contains(\"Approved\"));\n            assertTrue(pr.store().body().contains(\"[TEST-1](http://localhost/project/testTEST-1) needs critical request\"));\n            assertEquals(\"⚠️  @user1 This change is now ready for you to apply for [critical request](https://example.com). \" +\n                            \"This can be done directly in each associated issue or by using the \" +\n                            \"[/approval](https://wiki.openjdk.org/display/SKARA/Pull+Request+Commands#PullRequestCommands-/approval) command.\" +\n                            \"<!-- PullRequestBot approval needed comment -->\"\n                    , pr.store().comments().get(2).body());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/IssueTests.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.Repository;\n\nimport java.io.IOException;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\nimport static org.openjdk.skara.issuetracker.jira.JiraProject.SUBCOMPONENT;\n\nclass IssueTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            // No arguments\n            pr.addComment(\"/issue\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a help message\n            assertLastCommentContains(pr,\"Command syntax:\");\n            assertLastCommentContains(pr,  \"`/issue\");\n\n            // Check that the alias works as well\n            pr.addComment(\"/solves\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a help message\n            assertLastCommentContains(pr,\"Command syntax:\");\n            assertLastCommentContains(pr,  \"`/solves\");\n\n            // Invalid syntax\n            pr.addComment(\"/issue something I guess\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a failure message\n            assertLastCommentContains(pr,\"Command syntax\");\n\n            // Add an issue\n            pr.addComment(\"/issue 1234: An issue\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"Adding additional\");\n\n            // Try to remove a not-previously-added issue\n            pr.addComment(\"/issue remove 1235\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a failure message\n            assertLastCommentContains(pr,\"was not found\");\n\n            // Now remove the added one\n            pr.addComment(\"/issue remove 1234\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"Removing additional\");\n\n            // Add two more issues\n            pr.addComment(\"/issue 12345: Another issue\");\n            pr.addComment(\"/issue 123456: Yet another issue\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"Adding additional\");\n\n            // Update the description of the first one\n            pr.addComment(\"/issue 12345: This is indeed another issue\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"Updating description\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The commit message preview should contain the additional issues\n            var preview = pr.comments().stream()\n                            .filter(comment -> comment.body().contains(\"the commit message for the final commit will be\"))\n                            .map(Comment::body)\n                            .findFirst()\n                            .orElseThrow();\n            assertTrue(preview.contains(\"123: This is a pull request\"));\n            assertTrue(preview.contains(\"12345: This is indeed another issue\"));\n            assertTrue(preview.contains(\"123456: Yet another issue\"));\n\n            // Integrate\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an ok message\n            assertLastCommentContains(pr,\"Pushed as commit\");\n\n            // The change should now be present on the master branch\n            var pushedFolder = tempFolder.path().resolve(\"pushed\");\n            var pushedRepo = Repository.materialize(pushedFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n\n            // The additional issues should be present in the commit message\n            assertEquals(List.of(\"123: This is a pull request\",\n                                 \"12345: This is indeed another issue\",\n                                 \"123456: Yet another issue\",\n                                 \"\",\n                                 \"Reviewed-by: integrationreviewer1\"), headCommit.message());\n        }\n    }\n\n    @Test\n    void multiple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var issue1 = credentials.createIssue(issues, \"Main\");\n            var issue1Number = Integer.parseInt(issue1.id().split(\"-\")[1]);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue1Number + \": Main\");\n\n            var issue2 = credentials.createIssue(issues, \"Second\");\n            var issue2Number = Integer.parseInt(issue2.id().split(\"-\")[1]);\n            var issue3 = credentials.createIssue(issues, \"Third\");\n            var issue3Number = Integer.parseInt(issue3.id().split(\"-\")[1]);\n\n            // Add a single issue with the shorthand syntax\n            pr.addComment(\"/solves \" + issue3Number);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Adding additional issue to solves list\");\n            assertLastCommentContains(pr, \": Third\");\n\n            // And remove it\n            pr.addComment(\"/solves delete \" + issue3Number);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Removing additional issue from solves list: `\" + issue3Number + \"`\");\n\n            // Add two issues with the shorthand syntax\n            pr.addComment(\"/issue \" + issue2.id() + \",\" + issue3Number);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should add both\n            assertLastCommentContains(pr, \"Adding additional issue to issue list\");\n            assertLastCommentContains(pr, \": Second\");\n            assertLastCommentContains(pr, \": Third\");\n\n            // Update the title of issue2 and issue3\n            issue2.setTitle(\"Second2\");\n            issue3.setTitle(\"Third3\");\n            pr.setBody(\"update this pr\");\n            TestBotRunner.runPeriodicItems(prBot);\n            // PR body shouldn't contain title mismatch warning\n            assertFalse(pr.store().body().contains(\"Title mismatch between PR and JBS for issue\"));\n\n            // Remove one\n            pr.addComment(\"/issue remove \" + issue2.id());\n            TestBotRunner.runPeriodicItems(prBot);\n\n            assertLastCommentContains(pr, \"Removing additional issue from issue list: `\" + issue2Number + \"`\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The commit message preview should contain the additional issues\n            var preview = pr.comments().stream()\n                            .filter(comment -> comment.body().contains(\"the commit message for the final commit will be\"))\n                            .map(Comment::body)\n                            .findFirst()\n                            .orElseThrow();\n            assertTrue(preview.contains(issue1Number + \": Main\"));\n            assertTrue(preview.contains(issue3Number + \": Third3\"));\n            assertFalse(preview.contains(\"Second\"));\n\n            // Integrate\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an ok message\n            assertLastCommentContains(pr,\"Pushed as commit\");\n\n            // The change should now be present on the master branch\n            var pushedFolder = tempFolder.path().resolve(\"pushed\");\n            var pushedRepo = Repository.materialize(pushedFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n\n            // The additional issues should be present in the commit message\n            assertEquals(List.of(issue1Number + \": Main\",\n                                 issue3Number + \": Third3\",\n                                 \"\",\n                                 \"Reviewed-by: integrationreviewer1\"), headCommit.message());\n        }\n    }\n\n    @Test\n    void invalidCommandAuthor(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var external = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue a solves command not as the PR author\n            var externalPr = external.pullRequest(pr.id());\n            externalPr.addComment(\"/issue 1234: an issue\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"Only the author\"))\n                          .count();\n            assertEquals(1, error);\n        }\n    }\n\n    @Test\n    void issueInTitle(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Add an issue\n            pr.addComment(\"/issue 1234: An issue\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"current title\");\n\n            assertEquals(\"1234: An issue\", pr.store().title());\n\n            // Update the issue description\n            pr.addComment(\"/issue 1234: Yes this is an issue\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"will now be updated\");\n\n            assertEquals(\"1234: Yes this is an issue\", pr.store().title());\n        }\n    }\n\n    @Test\n    void issueInBody(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var issue1 = issues.createIssue(\"First\", List.of(\"Hello\"), Map.of());\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\",\n                                                   issue1.id() + \": This is a pull request\");\n\n            // First check\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(issue1.id()));\n            assertTrue(pr.store().body().contains(\"First\"));\n            assertTrue(pr.store().body().contains(\"## Issue\\n\"));\n\n            // Add an extra issue\n            var issue2 = issues.createIssue(\"Second\", List.of(\"There\"), Map.of());\n            pr.addComment(\"/issue \" + issue2.id() + \": Description\");\n\n            // Check that the body was updated\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(issue1.id()));\n            assertTrue(pr.store().body().contains(\"First\"));\n            assertTrue(pr.store().body().contains(issue2.id()));\n            assertTrue(pr.store().body().contains(\"Second\"));\n            assertFalse(pr.store().body().contains(\"## Issue\\n\"));\n            assertTrue(pr.store().body().contains(\"## Issues\\n\"));\n        }\n    }\n\n    @Test\n    void closedIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var issue1 = (TestIssueTrackerIssue) issues.createIssue(\"First\", List.of(\"Hello\"), Map.of());\n            issue1.setState(Issue.State.CLOSED);\n            issue1.store().properties().put(\"resolution\", JSON.object().put(\"name\", JSON.of(\"Not an Issue\")));\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\",\n                                                   issue1.id() + \": This is a pull request\");\n\n            // First check\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(issue1.id()));\n            assertTrue(pr.store().body().contains(\"First\"));\n            assertTrue(pr.store().body().contains(\"## Issue\\n\"));\n            assertTrue(pr.store().body().contains(\"Issue is not open\"));\n        }\n    }\n\n    @Test\n    void resolvedIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var issue1 = (TestIssueTrackerIssue) issues.createIssue(\"First\", List.of(\"Hello\"), Map.of());\n            issue1.setState(Issue.State.RESOLVED);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\",\n                    issue1.id() + \": This is a pull request\");\n\n            // First check\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(issue1.id()));\n            assertTrue(pr.store().body().contains(\"First\"));\n            assertTrue(pr.store().body().contains(\"## Issue\\n\"));\n            assertTrue(pr.store().body().contains(\"Consider making this a \\\"backport pull request\\\" by setting\"));\n        }\n    }\n\n    @Test\n    void closedIssueBackport(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var issue1 = issues.createIssue(\"First\", List.of(\"Hello\"), Map.of());\n            issue1.setState(Issue.State.RESOLVED);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\",\n                                                   issue1.id() + \": This is a pull request\");\n            pr.addLabel(\"backport\");\n\n            // First check\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().body().contains(issue1.id()));\n            assertTrue(pr.store().body().contains(\"First\"));\n            assertTrue(pr.store().body().contains(\"## Issue\\n\"));\n            assertFalse(pr.store().body().contains(\"Issue is not open\"));\n        }\n    }\n\n    private static final Pattern addedIssuePattern = Pattern.compile(\"`(.*)` was successfully created\", Pattern.MULTILINE);\n\n    private static IssueTrackerIssue issueFromLastComment(PullRequest pr, IssueProject issueProject) {\n        var comments = pr.comments();\n        var lastComment = comments.getLast();\n        var addedIssueMatcher = addedIssuePattern.matcher(lastComment.body());\n        assertTrue(addedIssueMatcher.find(), lastComment.body());\n        return issueProject.issue(addedIssueMatcher.group(1)).orElseThrow();\n    }\n\n    @Test\n    void projectPrefix(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var issueProject = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issueProject)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Create issues\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            var issue2 = credentials.createIssue(issueProject, \"Issue 2\");\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", issue1.id() + \": This is a pull request\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Add variations of this issue\n            pr.addComment(\"/issue add \" + issue2.id().toLowerCase());\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"Adding additional issue to issue list\");\n\n            pr.addComment(\"/issue remove \" + issue2.id().toLowerCase());\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"Removing additional issue from issue list\");\n\n            // Add variations of this issue\n            pr.addComment(\"/issue add \" + issue2.id().toUpperCase());\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"Adding additional issue to issue list\");\n\n            pr.addComment(\"/issue remove \" + issue2.id().toUpperCase());\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"Removing additional issue from issue list\");\n\n            // Add variations of this issue\n            pr.addComment(\"/issue add \" + issue2.id().split(\"-\")[1]);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"Adding additional issue to issue list\");\n        }\n    }\n\n    @Test\n    void multipleIssuesInBody(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var issueProject = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issueProject)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            var issue2 = credentials.createIssue(issueProject, \"Issue 2\");\n            var issue3 = credentials.createIssue(issueProject, \"Issue 3\");\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Pull request title\",\n                     List.of(\"/issue add \" + issue1.id(),\n                             \"/issue add \" + issue2.id(),\n                             \"/issue add \" + issue3.id()));\n\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The first issue should be the title\n            assertTrue(pr.title().startsWith(issue1.id().split(\"-\")[1] + \": \"));\n\n            var comments = pr.comments();\n            assertEquals(4, comments.size());\n\n            assertTrue(comments.get(1).body().contains(\"current title does not contain an issue reference\"));\n            assertTrue(comments.get(2).body().contains(\"Adding additional issue to\"));\n            assertTrue(comments.get(3).body().contains(\"Adding additional issue to\"));\n        }\n    }\n\n    @Test\n    void issueMissing(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with a non-existing issue ID\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a PR\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // There should be no commit preview message\n            var previewComment = pr.comments().stream()\n                    .map(Comment::body)\n                    .filter(body -> body.contains(\"the commit message for the final commit will be\"))\n                    .findFirst();\n            assertEquals(Optional.empty(), previewComment, \"Preview comment should not have been posted\");\n            // Body should contain integration blocker\n            assertTrue(pr.store().body().contains(\"Integration blocker\"), \"Body does not report integration blocker\");\n            assertTrue(pr.store().body().contains(\"Failed to retrieve information on issue `123`\"),\n                    \"Body does not contain specific message\");\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/JEPCommandTests.java",
    "content": "/*\n * Copyright (c) 2022, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.test.CheckableRepository;\nimport org.openjdk.skara.test.HostCredentials;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestBotRunner;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\nimport static org.openjdk.skara.issuetracker.jira.JiraProject.JEP_NUMBER;\nimport static org.openjdk.skara.bots.common.PullRequestConstants.*;\n\npublic class JEPCommandTests {\n    @Test\n    void testNormal(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .enableJep(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(),\n                    Path.of(\"appendable.txt\"), Set.of(\"issues\"), null);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var mainIssue = issueProject.createIssue(\"The main issue\", List.of(\"main\"), Map.of(\"issuetype\", JSON.of(\"Bug\")));\n            var jepIssue = issueProject.createIssue(\"The jep issue\", List.of(\"Jep body\"),\n                    Map.of(\"issuetype\", JSON.of(\"JEP\"), \"status\", JSON.object().put(\"name\", \"Submitted\"), JEP_NUMBER, JSON.of(\"123\")));\n            var jepIssueTargeted = issueProject.createIssue(\"The jep issue\", List.of(\"Jep body\"),\n                    Map.of(\"issuetype\", JSON.of(\"JEP\"), \"status\", JSON.object().put(\"name\", \"Targeted\"), JEP_NUMBER, JSON.of(\"234\")));\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", mainIssue.id() + \": \" + mainIssue.title());\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n\n            // PR should not have the `jep` label\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n\n            // Require jep by using `JEP-<id>`\n            pr.addComment(\"/jep JEP-123\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"This pull request will not be integrated until the [JEP-\");\n            assertLastCommentContains(pr, \"has been targeted.\");\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a JEP request to be targeted\"));\n\n            // Not require jep\n            prAsReviewer.addComment(\"/jep unneeded\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"determined that the JEP request is not needed for this pull request.\");\n            assertFalse(pr.store().body().contains(\"Change requires a JEP request to be targeted\"));\n\n            // Require jep by using `jep-<id>`\n            pr.addComment(\"/jep jep-123\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"This pull request will not be integrated until the [JEP-\");\n            assertLastCommentContains(pr, \"has been targeted.\");\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a JEP request to be targeted\"));\n\n            // Not require jep\n            prAsReviewer.addComment(\"/jep unneeded\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"determined that the JEP request is not needed for this pull request.\");\n            assertFalse(pr.store().body().contains(\"Change requires a JEP request to be targeted\"));\n\n            // Require jep by using `Jep-<id>`\n            pr.addComment(\"/jep Jep-123\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"This pull request will not be integrated until the [JEP-\");\n            assertLastCommentContains(pr, \"has been targeted.\");\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a JEP request to be targeted\"));\n\n            // Not require jep\n            prAsReviewer.addComment(\"/jep unneeded\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"determined that the JEP request is not needed for this pull request.\");\n            assertFalse(pr.store().body().contains(\"Change requires a JEP request to be targeted\"));\n\n            // Require jep with strange jep prefix\n            pr.addComment(\"/jep jEP-123\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"This pull request will not be integrated until the [JEP-\");\n            assertLastCommentContains(pr, \"has been targeted.\");\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a JEP request to be targeted\"));\n\n            // Not require jep\n            prAsReviewer.addComment(\"/jep unneeded\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"determined that the JEP request is not needed for this pull request.\");\n            assertFalse(pr.store().body().contains(\"Change requires a JEP request to be targeted\"));\n\n            // Require jep by using `issue-id`(<ProjectName>-<id>)\n            pr.addComment(\"/jep TEST-3\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"The JEP for this pull request, [JEP-\");\n            assertLastCommentContains(pr, \"has already been targeted.\");\n            assertTrue(pr.store().body().contains(\"- [x] Change requires a JEP request to be targeted\"));\n\n            // Not require jep by using `uneeded`\n            prAsReviewer.addComment(\"/jep uneeded\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"determined that the JEP request is not needed for this pull request.\");\n            assertFalse(pr.store().body().contains(\"Change requires a JEP request to be targeted\"));\n\n            // Require jep by using `issue-id` which doesn't have the project name\n            pr.addComment(\"/jep 3\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"The JEP for this pull request, [JEP-\");\n            assertLastCommentContains(pr, \"has already been targeted.\");\n            assertTrue(pr.store().body().contains(\"- [x] Change requires a JEP request to be targeted\"));\n\n            // Not require jep\n            prAsReviewer.addComment(\"/jep unneeded\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"determined that the JEP request is not needed for this pull request.\");\n            assertFalse(pr.store().body().contains(\"Change requires a JEP request to be targeted\"));\n\n            // Require jep with right JEP ID without prefix\n            pr.addComment(\"/jep 123\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"This pull request will not be integrated until the [JEP-\");\n            assertLastCommentContains(pr, \"has been targeted.\");\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a JEP request to be targeted\"));\n        }\n    }\n\n    @Test\n    void testAuthorization(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var committer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addCommitter(committer.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .enableJep(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(),\n                    Path.of(\"appendable.txt\"), Set.of(\"issues\"), null);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var mainIssue = issueProject.createIssue(\"The main issue\", List.of(\"main\"), Map.of(\"issuetype\", JSON.of(\"Bug\")));\n            var jepIssue = issueProject.createIssue(\"The jep issue\", List.of(\"Jep body\"),\n                    Map.of(\"issuetype\", JSON.of(\"JEP\"), \"status\", JSON.object().put(\"name\", \"Submitted\"), JEP_NUMBER, JSON.of(\"123\")));\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", mainIssue.id() + \": \" + mainIssue.title());\n            var prAsReviewer = reviewer.pullRequest(pr.id());\n            var prAsCommitter = committer.pullRequest(pr.id());\n\n            // PR should not have the `jep` label\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n\n            // Require jep by a committer who is not the PR author\n            prAsCommitter.addComment(\"/jep JEP-123\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"Only the pull request author and [Reviewers]\" +\n                    \"(https://openjdk.org/bylaws#reviewer) are allowed to use the `jep` command.\");\n\n            // Require jep by the PR author\n            pr.addComment(\"/jep TEST-2\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"This pull request will not be integrated until the [JEP-\");\n            assertLastCommentContains(pr, \"has been targeted.\");\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a JEP request to be targeted\"));\n\n            // Require jep by a reviewer\n            prAsReviewer.addComment(\"/jep TEST-2\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"This pull request will not be integrated until the [JEP-\");\n            assertLastCommentContains(pr, \"has been targeted.\");\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a JEP request to be targeted\"));\n\n            // Not require jep by a committer\n            prAsCommitter.addComment(\"/jep unneeded\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"Only the pull request author and [Reviewers]\" +\n                    \"(https://openjdk.org/bylaws#reviewer) are allowed to use the `jep` command.\");\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a JEP request to be targeted\"));\n\n            // Not require jep by a reviewer\n            prAsReviewer.addComment(\"/jep unneeded\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"determined that the JEP request is not needed for this pull request.\");\n            assertFalse(pr.store().body().contains(\"Change requires a JEP request to be targeted\"));\n        }\n    }\n\n    @Test\n    void testIssueTypo(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .enableJep(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(),\n                    Path.of(\"appendable.txt\"), Set.of(\"issues\"), null);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var mainIssue = issueProject.createIssue(\"The main issue\", List.of(\"main\"), Map.of(\"issuetype\", JSON.of(\"Bug\")));\n            var jepIssue = issueProject.createIssue(\"The jep issue\", List.of(\"Jep body\"),\n                    Map.of(\"issuetype\", JSON.of(\"JEP\"), \"status\", JSON.object().put(\"name\", \"Submitted\"), JEP_NUMBER, JSON.of(\"123\")));\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", mainIssue.id() + \": \" + mainIssue.title());\n\n            // PR should not have the `jep` label\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n\n            // Require jep with blank value\n            pr.addComment(\"/jep\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertEquals(3, pr.comments().size());\n            assertLastCommentContains(pr, \"Command syntax:\");\n            assertLastCommentContains(pr, \"Some examples:\");\n            // Test the symbol `\\` of the text block\n            assertLastCommentContains(pr, \"The prefix (i.e. `JDK-`, `JEP-` or `jep-`) is optional. If the argument is given without prefix, \"\n                    + \"it will be tried first as a JEP ID and second as an issue ID. The issue type must be `JEP`.\");\n            assertFalse(pr.store().body().contains(\"Change requires a JEP request to be targeted\"));\n\n            // Require jep with blank space\n            pr.addComment(\"/jep   \");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertEquals(5, pr.comments().size());\n            assertLastCommentContains(pr, \"Command syntax:\");\n            assertLastCommentContains(pr, \"Some examples:\");\n            // Test the symbol `\\` of the text block\n            assertLastCommentContains(pr, \"The prefix (i.e. `JDK-`, `JEP-` or `jep-`) is optional. If the argument is given without prefix, \"\n                    + \"it will be tried first as a JEP ID and second as an issue ID. The issue type must be `JEP`.\");\n            assertFalse(pr.store().body().contains(\"Change requires a JEP request to be targeted\"));\n\n            // Require jep with wrong jep prefix\n            pr.addComment(\"/jep je-123\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertEquals(7, pr.comments().size());\n            assertLastCommentContains(pr, \"The JEP issue was not found. Please make sure you have entered it correctly.\");\n            assertFalse(pr.store().body().contains(\"Change requires a JEP request to be targeted\"));\n\n            // Require jep with wrong jep id without prefix\n            pr.addComment(\"/jep 1\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertEquals(9, pr.comments().size());\n            assertLastCommentContains(pr, \"The issue `TEST-1` is not a JEP. Please make sure you have entered it correctly.\");\n            assertFalse(pr.store().body().contains(\"Change requires a JEP request to be targeted\"));\n\n            // Require jep with wrong project prefix\n            pr.addComment(\"/jep TESt-2\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertEquals(11, pr.comments().size());\n            assertLastCommentContains(pr, \"The JEP issue was not found. Please make sure you have entered it correctly.\");\n            assertFalse(pr.store().body().contains(\"Change requires a JEP request to be targeted\"));\n\n            // Require jep with wrong `jep-id`\n            pr.addComment(\"/jep jep-1\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertEquals(13, pr.comments().size());\n            assertLastCommentContains(pr, \"The JEP issue was not found. Please make sure you have entered it correctly.\");\n            assertFalse(pr.store().body().contains(\"Change requires a JEP request to be targeted\"));\n\n            // Require jep with wrong issue type\n            pr.addComment(\"/jep TEST-1\");\n\n            // Verify the behavior\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertEquals(15, pr.comments().size());\n            assertLastCommentContains(pr, \"The issue `TEST-1` is not a JEP. Please make sure you have entered it correctly.\");\n            assertFalse(pr.store().body().contains(\"Change requires a JEP request to be targeted\"));\n        }\n    }\n\n    @Test\n    void testJepIssueStatus(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .censusRepo(censusBuilder.build())\n                    .enableJep(true)\n                    .issuePRMap(new HashMap<>())\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(),\n                    Path.of(\"appendable.txt\"), Set.of(\"issues\"), null);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var mainIssue = issueProject.createIssue(\"The main issue\", List.of(\"main\"), Map.of(\"issuetype\", JSON.of(\"Bug\")));\n            var statusList = List.of(\"Draft\", \"Submitted\", \"Candidate\", \"Proposed to Target\",\n                    \"Proposed to Drop\", \"Closed\", \"Targeted\", \"Integrated\", \"Completed\");\n            for (int i = 1; i <= 9; i++) {\n                issueProject.createIssue(statusList.get(i - 1) + \" jep\", List.of(\"Jep body\"), Map.of(\"issuetype\", JSON.of(\"JEP\"),\n                        \"status\", JSON.object().put(\"name\", statusList.get(i - 1)), JEP_NUMBER, JSON.of(String.valueOf(i))));\n            }\n            issueProject.createIssue(\"The jep issue\", List.of(\"Jep body\"),\n                    Map.of(\"issuetype\", JSON.of(\"JEP\"), \"status\", JSON.object().put(\"name\", \"Closed\"),\n                           \"resolution\", JSON.object().put(\"name\", \"Delivered\"), JEP_NUMBER, JSON.of(\"10\")));\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", mainIssue.id() + \": \" + mainIssue.title());\n\n            // PR should not have the `jep` label\n            TestBotRunner.runPeriodicItems(prBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n\n            // Test draft/submitted/candidate/proposedToTarget/proposedToDrop/closedWithoutDelivered JEPs\n            for (int i = 1; i <= 6; i++) {\n                pr.addComment(\"/jep jep-\" + i);\n                TestBotRunner.runPeriodicItems(prBot);\n                assertTrue(pr.store().labelNames().contains(JEP_LABEL));\n                assertEquals(i * 2 + 1, pr.comments().size());\n                assertLastCommentContains(pr, \"This pull request will not be integrated until the [JEP-\");\n                assertLastCommentContains(pr, \"has been targeted.\");\n                assertTrue(pr.store().body().contains(\"- [ ] Change requires a JEP request to be targeted\"));\n            }\n\n            // Test targeted/integrated/completed/closedWithDelivered JEPs\n            for (int i = 7; i <= 10; i++) {\n                pr.addComment(\"/jep jep-\" + i);\n                TestBotRunner.runPeriodicItems(prBot);\n                assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n                assertEquals(i * 2 + 1, pr.comments().size());\n                assertLastCommentContains(pr, \"The JEP for this pull request, [JEP-\");\n                assertLastCommentContains(pr, \"has already been targeted.\");\n                assertTrue(pr.store().body().contains(\"- [x] Change requires a JEP request to be targeted\"));\n            }\n        }\n    }\n\n    @Test\n    void testEnableJepConfig(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id());\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(),\n                    Path.of(\"appendable.txt\"), Set.of(\"issues\"), null);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var mainIssue = issueProject.createIssue(\"The main issue\", List.of(\"main\"), Map.of(\"issuetype\", JSON.of(\"Bug\")));\n            var jepIssue = issueProject.createIssue(\"The jep issue\", List.of(\"Jep body\"),\n                    Map.of(\"issuetype\", JSON.of(\"JEP\"), \"status\", JSON.object().put(\"name\", \"Submitted\"), JEP_NUMBER, JSON.of(\"123\")));\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", mainIssue.id() + \": \" + mainIssue.title());\n\n            // Test the PR bot with jep disable\n            var disableJepBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .enableJep(false)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(new HashMap<>())\n                    .build();\n            pr.addComment(\"/jep TEST-2\");\n            TestBotRunner.runPeriodicItems(disableJepBot);\n            assertLastCommentContains(pr, \"This repository has not been configured to use the `jep` command.\");\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertFalse(pr.store().body().contains(\"- [ ] Change requires a JEP request to be targeted\"));\n\n            // Test the PR bot with jep enable\n            var enableJepBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .enableJep(true)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(new HashMap<>())\n                    .build();\n            pr.addComment(\"/jep TEST-2\");\n            TestBotRunner.runPeriodicItems(enableJepBot);\n            assertLastCommentContains(pr, \"pull request will not be integrated until the\");\n            assertTrue(pr.store().labelNames().contains(JEP_LABEL));\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a JEP request to be targeted\"));\n        }\n    }\n\n    @Test\n    void testWithoutJEPNumber(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var issueProject = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id());\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(),\n                    Path.of(\"appendable.txt\"), Set.of(\"issues\"), null);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var mainIssue = issueProject.createIssue(\"The main issue\", List.of(\"main\"), Map.of(\"issuetype\", JSON.of(\"Bug\")));\n            var jepIssue = issueProject.createIssue(\"The jep issue\", List.of(\"Jep body\"),\n                    Map.of(\"issuetype\", JSON.of(\"JEP\"), \"status\", JSON.object().put(\"name\", \"Submitted\")));\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", mainIssue.id() + \": \" + mainIssue.title());\n\n            // Test the PR bot with jep\n            Map<String, List<PRRecord>> issuePRMap = new HashMap<>();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .issueProject(issueProject)\n                    .enableJep(true)\n                    .censusRepo(censusBuilder.build())\n                    .issuePRMap(issuePRMap)\n                    .build();\n            var issueBot = new IssueBot(issueProject, List.of(author), Map.of(bot.name(), prBot), issuePRMap);\n\n            pr.addComment(\"/jep TEST-2\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertTrue(pr.store().labelNames().contains(JEP_LABEL));\n            assertLastCommentContains(pr, \"pull request will not be integrated until the\");\n            assertTrue(pr.store().body().contains(\"- [ ] Change requires a JEP request to be targeted\"));\n\n            // Make the jep issue Targeted\n            jepIssue.setProperty(\"status\", JSON.object().put(\"name\", \"Targeted\"));\n            jepIssue.setProperty(JEP_NUMBER, JSON.of(\"123\"));\n            TestBotRunner.runPeriodicItems(issueBot);\n            assertFalse(pr.store().labelNames().contains(JEP_LABEL));\n            assertTrue(pr.store().body().contains(\"- [x] Change requires a JEP request to be targeted\"));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/LabelTests.java",
    "content": "/*\n * Copyright (c) 2020, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.*;\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertFirstCommentContains;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\npublic class LabelTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var labelConfiguration = LabelConfigurationJson.builder()\n                                                           .addMatchers(\"1\", List.of(Pattern.compile(\"cpp$\")))\n                                                           .addMatchers(\"2\", List.of(Pattern.compile(\"hpp$\")))\n                                                           .addGroup(\"group\", List.of(\"1\", \"2\"))\n                                                           .addExtra(\"extra\")\n                                                           .build();\n            var prBot = PullRequestBot.newBuilder()\n                                      .repo(integrator)\n                                      .censusRepo(censusBuilder.build())\n                                      .labelConfiguration(labelConfiguration)\n                                      .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // No arguments\n            pr.addComment(\"/label\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a help message\n            assertLastCommentContains(pr,\"Usage: `/label\");\n\n            // Check that the alias works as well\n            pr.addComment(\"/cc\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a help message\n            assertLastCommentContains(pr,\"Usage: `/cc\");\n\n            // Invalid label\n            pr.addComment(\"/label add unknown\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a failure message\n            assertLastCommentContains(pr,\"The label `unknown` is not a valid label\");\n            assertLastCommentContains(pr,\"* `1`\");\n            assertLastCommentContains(pr,\"* `group`\");\n            assertLastCommentContains(pr,\"* `extra`\");\n\n            // Add a label\n            pr.addComment(\"/skara label add 1\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"The `1` label was successfully added.\");\n\n            // One more\n            pr.addComment(\"/cc group\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(pr.store().labelNames().contains(\"group\"));\n            assertTrue(pr.store().labelNames().contains(\"1\"));\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"The `group` label was successfully added.\");\n\n            // Drop group\n            pr.addComment(\"        /skara label remove   group\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"The `group` label was successfully removed.\");\n\n            // And once more\n            pr.addComment(\"   /skara    label add 2, extra\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"The `2` label was successfully added.\");\n            assertLastCommentContains(pr,\"The `extra` label was successfully added.\");\n        }\n    }\n\n    @Test\n    void adjustAutoApplied(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var labelConfiguration = LabelConfigurationJson.builder()\n                                                           .addMatchers(\"1\", List.of(Pattern.compile(\"cpp$\")))\n                                                           .addMatchers(\"2\", List.of(Pattern.compile(\"hpp$\")))\n                                                           .addGroup(\"group\", List.of(\"1\", \"2\"))\n                                                           .addGroup(\"group2\", List.of(\"1\", \"3\"))\n                                                           .addExtra(\"extra\")\n                                                           .build();\n            var prBot = PullRequestBot.newBuilder()\n                                      .repo(integrator)\n                                      .censusRepo(censusBuilder.build())\n                                      .labelConfiguration(labelConfiguration)\n                                      .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType(), Path.of(\"test.hpp\"));\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            // The bot should have applied one label automatically\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(Set.of(\"2\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n            assertLastCommentContains(pr, \"The following label will be automatically applied\");\n            assertLastCommentContains(pr, \"`2`\");\n\n            // The bot will remove the label\n            pr.addComment(\"/label remove 2\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"The `2` label was successfully removed.\");\n\n            // Add another file that would have trigger a group match\n            Files.writeString(localRepoFolder.resolve(\"test.cpp\"), \"Hello there\");\n            localRepo.add(Path.of(\"test.cpp\"));\n            editHash = localRepo.commit(\"Another one\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\");\n\n            // The bot should add label \"1\" since test.cpp is touched\n            TestBotRunner.runPeriodicItems(prBot);\n            // After label \"1\" is added, in the next CheckWorkItem, rfr should be added\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(Set.of(\"1\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n\n            // Adding the label manually is fine\n            pr.addComment(\"/label add group\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"The `group` label was successfully added.\");\n            assertEquals(Set.of(\"1\", \"group\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n\n            pr.addComment(\"/label add group2\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"The `group2` label was successfully added.\");\n            assertEquals(Set.of(\"1\", \"group\", \"group2\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n        }\n    }\n\n    @Test\n    void overrideAutoApplied(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var labelConfiguration = LabelConfigurationJson.builder()\n                                                           .addMatchers(\"1\", List.of(Pattern.compile(\"cpp$\")))\n                                                           .addMatchers(\"2\", List.of(Pattern.compile(\"hpp$\")))\n                                                           .addGroup(\"group\", List.of(\"1\", \"2\"))\n                                                           .addExtra(\"extra\")\n                                                           .build();\n            var prBot = PullRequestBot.newBuilder()\n                                      .repo(integrator)\n                                      .censusRepo(censusBuilder.build())\n                                      .labelConfiguration(labelConfiguration)\n                                      .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType(), Path.of(\"test.hpp\"));\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n            pr.setBody(\"/cc 1\");\n\n            // Although manually added label 1, the auto labeling should still happen\n            TestBotRunner.runPeriodicItems(prBot);\n            // Since there is already a component associated, rfr should be added\n            assertLastCommentContains(pr, \"The following label will be automatically applied to this pull request:\");\n            // hpp file would let the bot add label \"2\", since the user manually added \"1\", so \"2\" will be upgraded to \"group\"\n            assertEquals(Set.of(\"1\", \"group\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n            assertEquals(3, pr.comments().size());\n            assertTrue(pr.store().comments().get(1).body().contains(\"The `1` label was successfully added.\"));\n\n            // Add another file to trigger label 2\n            Files.writeString(localRepoFolder.resolve(\"test2.hpp\"), \"Hello there\");\n            localRepo.add(Path.of(\"test2.hpp\"));\n            editHash = localRepo.commit(\"Another one\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\");\n\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(Set.of(\"1\", \"group\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n\n            // Adding manually is still fine\n            pr.addComment(\"/label add group 2\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"The `group` label was already applied.\");\n            assertLastCommentContains(pr, \"The `2` label was successfully added.\");\n            assertEquals(Set.of(\"1\", \"2\", \"group\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n        }\n    }\n\n    @Test\n    void commandAuthor(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var other = credentials.getHostedRepository();\n            var committer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(committer.forge().currentUser().id())\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addAuthor(other.forge().currentUser().id());\n            var labelConfiguration = LabelConfigurationJson.builder()\n                                                           .addMatchers(\"1\", List.of(Pattern.compile(\"cpp$\")))\n                                                           .addMatchers(\"2\", List.of(Pattern.compile(\"hpp$\")))\n                                                           .addGroup(\"group\", List.of(\"1\", \"2\"))\n                                                           .addExtra(\"extra\")\n                                                           .build();\n            var prBot = PullRequestBot.newBuilder()\n                                      .repo(integrator)\n                                      .censusRepo(censusBuilder.build())\n                                      .labelConfiguration(labelConfiguration)\n                                      .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Non committers cannot modify labels\n            var otherPr = other.pullRequest(pr.id());\n            otherPr.addComment(\"/label extra\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Only the PR author and project [Committers]\");\n\n            // But PR authors can\n            pr.addComment(\"/label extra\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"The `extra` label was successfully added\");\n\n            // As well as other committers\n            var committerPr = committer.pullRequest(pr.id());\n            committerPr.addComment(\"/label 2\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"The `2` label was successfully added\");\n        }\n    }\n\n    @Test\n    void stripSuffix(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var labelConfiguration = LabelConfigurationJson.builder()\n                                                       .addMatchers(\"1\", List.of(Pattern.compile(\"cpp$\")))\n                                                       .addMatchers(\"2\", List.of(Pattern.compile(\"hpp$\")))\n                                                       .addGroup(\"group\", List.of(\"1\", \"2\"))\n                                                       .addExtra(\"extra\")\n                                                       .build();\n            var prBot = PullRequestBot.newBuilder()\n                                      .repo(integrator)\n                                      .censusRepo(censusBuilder.build())\n                                      .labelConfiguration(labelConfiguration)\n                                      .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Add a label with -dev suffix\n            pr.addComment(\"/label add 1-dev\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"The `1` label was successfully added.\");\n\n            // One more\n            pr.addComment(\"/cc group-dev@openjdk.org\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"The `group` label was successfully added.\");\n        }\n    }\n\n    @Test\n    void twentyFourHoursLabel(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var labelConfiguration = LabelConfigurationJson.builder()\n                                                       .addMatchers(\"1\", List.of(Pattern.compile(\"cpp$\")))\n                                                       .addMatchers(\"2\", List.of(Pattern.compile(\"hpp$\")))\n                                                       .addGroup(\"group\", List.of(\"1\", \"2\"))\n                                                       .addExtra(\"extra\")\n                                                       .build();\n            var prBot = PullRequestBot.newBuilder()\n                                      .repo(integrator)\n                                      .censusRepo(censusBuilder.build())\n                                      .twentyFourHoursLabels(Set.of(\"1\"))\n                                      .labelConfiguration(labelConfiguration)\n                                      .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Add a label with 24h hint\n            pr.addComment(\"/label add 1\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"The `1` label was successfully added.\");\n\n            // Review the PR\n            var prAsReviewer = integrator.pullRequest(pr.id());\n            prAsReviewer.addReview(Review.Verdict.APPROVED, \"Looks good!\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a integration message\n            assertFirstCommentContains(pr,\"This change now passes all *automated* pre-integration checks\");\n            assertFirstCommentContains(pr,\":earth_americas: Applicable reviewers for one or more changes \");\n            assertFirstCommentContains(pr,\"in this pull request are spread across multiple different time zones.\");\n            assertFirstCommentContains(pr,\"been out for review for at least 24 hours\");\n        }\n    }\n\n    @Test\n    void shortArgument(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            var labelConfiguration = LabelConfigurationJson.builder()\n                    .addMatchers(\"1\", List.of(Pattern.compile(\"cpp$\")))\n                    .addMatchers(\"2\", List.of(Pattern.compile(\"hpp$\")))\n                    .addGroup(\"group\", List.of(\"1\", \"2\"))\n                    .addExtra(\"extra\")\n                    .build();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .labelConfiguration(labelConfiguration)\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Add a label without `+`\n            pr.addComment(\"/label 1\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"The `1` label was successfully added.\");\n            assertEquals(Set.of(\"1\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n\n            // Add a label without `+` and check that the alias works as well\n            pr.addComment(\"/cc 2\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"The `2` label was successfully added.\");\n            // label \"1\" and \"2\" should be upgraded to \"group\"\n            assertEquals(Set.of(\"1\", \"2\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n\n            // Remove a label with `-`\n            pr.addComment(\"/label -group\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr, \"The `group` label was not set.\");\n            // The rfr label should be removed because the pr is not associated with any component\n            assertEquals(Set.of(\"1\", \"2\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n\n            // Add a label with `+`\n            pr.addComment(\"/label +group\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr, \"The `group` label was successfully added.\");\n            assertEquals(Set.of(\"1\", \"2\", \"rfr\", \"group\"), new HashSet<>(pr.store().labelNames()));\n\n            // Mixed `+/-` labels\n            pr.addComment(\"/label +2,-group\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with the success messages\n            assertLastCommentContains(pr,\"The `2` label was already applied.\");\n            assertLastCommentContains(pr,\"The `group` label was successfully removed.\");\n            assertEquals(Set.of(\"1\", \"2\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n\n            // Mixed `+/-` labels again and check that the alias works as well\n            pr.addComment(\"/label group, +1, -2\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with the success messages\n            assertLastCommentContains(pr,\"The `group` label was successfully added.\");\n            assertLastCommentContains(pr,\"The `1` label was already applied\");\n            assertLastCommentContains(pr,\"The `2` label was successfully removed.\");\n            assertEquals(Set.of(\"1\", \"rfr\", \"group\"), new HashSet<>(pr.store().labelNames()));\n\n            // Mixed `+/-` labels and intentional whitespace.\n            pr.addComment(\"/label - 1, + 2, - group\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a help message\n            assertLastCommentContains(pr,\"Usage: `/label\");\n            assertEquals(Set.of(\"1\", \"rfr\", \"group\"), new HashSet<>(pr.store().labelNames()));\n\n            // Mixed normal and short labels\n            pr.addComment(\"/label add +2, -group\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a help message\n            assertLastCommentContains(pr,\"Usage: `/label\");\n            assertEquals(Set.of(\"1\", \"rfr\", \"group\"), new HashSet<>(pr.store().labelNames()));\n\n            // Check unknown labels\n            pr.addComment(\"/label +unknown1, -unknown2, unknown3\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a failure message\n            assertLastCommentContains(pr,\"The label `unknown1` is not a valid label\");\n            assertLastCommentContains(pr,\"The label `unknown2` is not a valid label\");\n            assertLastCommentContains(pr,\"The label `unknown3` is not a valid label\");\n            assertLastCommentContains(pr,\"* `1`\");\n            assertLastCommentContains(pr,\"* `group`\");\n            assertLastCommentContains(pr,\"* `extra`\");\n            assertEquals(Set.of(\"1\", \"rfr\", \"group\"), new HashSet<>(pr.store().labelNames()));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/LabelerTests.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.jcheck.ReviewersCheck;\nimport org.openjdk.skara.test.*;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\nclass LabelerTests {\n    @Test\n    void noMatch(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var labelConfiguration = LabelConfigurationJson.builder()\n                                                           .addMatchers(\"test1\", List.of(Pattern.compile(\"a.txt\")))\n                                                           .addMatchers(\"test2\", List.of(Pattern.compile(\"b.txt\")))\n                                                           .build();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var labelBot = PullRequestBot.newBuilder()\n                                         .repo(author)\n                                         .censusRepo(censusBuilder.build())\n                                         .labelConfiguration(labelConfiguration)\n                                         .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path();\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status - rfr label should not be set since the pr is not associated with any component\n            TestBotRunner.runPeriodicItems(labelBot);\n            assertEquals(Set.of(), new HashSet<>(pr.store().labelNames()));\n            assertLastCommentContains(pr, \"However, no automatic labelling rule matches the changes in this pull request.\");\n            assertLastCommentContains(pr, \"<details>\");\n            assertLastCommentContains(pr, \"<summary>Applicable Labels</summary>\");\n            assertLastCommentContains(pr, \"- `test1`\");\n            assertLastCommentContains(pr, \"- `test2`\");\n            assertLastCommentContains(pr, \"</details>\");\n        }\n    }\n\n    @Test\n    void match(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var labelConfiguration = LabelConfigurationJson.builder()\n                                                           .addMatchers(\"test1\", List.of(Pattern.compile(\"a.txt\")))\n                                                           .addMatchers(\"test2\", List.of(Pattern.compile(\"b.txt\")))\n                                                           .build();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var labelBot = PullRequestBot.newBuilder()\n                                         .repo(author)\n                                         .censusRepo(censusBuilder.build())\n                                         .labelConfiguration(labelConfiguration)\n                                         .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path();\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n\n            var fileA = localRepoFolder.resolve(\"a.txt\");\n            Files.writeString(fileA, \"Hello\");\n            localRepo.add(fileA);\n            var hashA = localRepo.commit(\"test1\", \"test\", \"test@test\");\n            localRepo.push(hashA, author.authenticatedUrl(), \"edit\");\n\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status - there should now be a test1 label\n            TestBotRunner.runPeriodicItems(labelBot);\n            assertEquals(Set.of(\"rfr\", \"test1\"), new HashSet<>(pr.store().labelNames()));\n        }\n    }\n\n    @Test\n    void copy(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var labelConfiguration = LabelConfigurationJson.builder()\n                                                           .addMatchers(\"test1\", List.of(Pattern.compile(\"a.txt\")))\n                                                           .addMatchers(\"test2\", List.of(Pattern.compile(\"b.txt\")))\n                                                           .build();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var labelBot = PullRequestBot.newBuilder()\n                                         .repo(author)\n                                         .censusRepo(censusBuilder.build())\n                                         .labelConfiguration(labelConfiguration)\n                                         .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path();\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add an unrelated file to master\n            var fileB = localRepoFolder.resolve(\"b.txt\");\n            Files.writeString(fileB, \"Hello\");\n            localRepo.add(fileB);\n            var hashB = localRepo.commit(\"test1\", \"test\", \"test@test\");\n            localRepo.push(hashB, author.authenticatedUrl(), \"master\");\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n\n            var fileA = localRepoFolder.resolve(\"a.txt\");\n            Files.writeString(fileA, \"Hello\");\n            localRepo.add(fileA);\n            var hashA = localRepo.commit(\"test1\", \"test\", \"test@test\");\n            localRepo.push(hashA, author.authenticatedUrl(), \"edit\");\n\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status - there should now be a test1 label\n            TestBotRunner.runPeriodicItems(labelBot);\n            assertEquals(Set.of(\"rfr\", \"test1\"), new HashSet<>(pr.store().labelNames()));\n        }\n    }\n\n    @Test\n    void initialLabelCommand(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var labelConfiguration = LabelConfigurationJson.builder()\n                                                           .addMatchers(\"test1\", List.of(Pattern.compile(\"a.txt\")))\n                                                           .addMatchers(\"test2\", List.of(Pattern.compile(\"b.txt\")))\n                                                           .build();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var labelBot = PullRequestBot.newBuilder()\n                                         .repo(author)\n                                         .censusRepo(censusBuilder.build())\n                                         .labelConfiguration(labelConfiguration)\n                                         .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path();\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n\n            var fileA = localRepoFolder.resolve(\"a.txt\");\n            Files.writeString(fileA, \"Hello\");\n            localRepo.add(fileA);\n            var hashA = localRepo.commit(\"test1\", \"test\", \"test@test\");\n            localRepo.push(hashA, author.authenticatedUrl(), \"edit\");\n\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue a manual label command, this shouldn't affect the auto labeling\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/label add test2\");\n\n            // Check the status - there should be test1 and test2\n            TestBotRunner.runPeriodicItems(labelBot);\n            assertEquals(Set.of(\"rfr\", \"test2\", \"test1\"), new HashSet<>(pr.store().labelNames()));\n        }\n    }\n\n    @Test\n    void initialLabel(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var labelConfiguration = LabelConfigurationJson.builder()\n                                                           .addMatchers(\"test1\", List.of(Pattern.compile(\"a.txt\")))\n                                                           .addMatchers(\"test2\", List.of(Pattern.compile(\"b.txt\")))\n                                                           .build();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var labelBot = PullRequestBot.newBuilder()\n                                         .repo(author)\n                                         .censusRepo(censusBuilder.build())\n                                         .labelConfiguration(labelConfiguration)\n                                         .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path();\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n\n            var fileA = localRepoFolder.resolve(\"a.txt\");\n            Files.writeString(fileA, \"Hello\");\n            localRepo.add(fileA);\n            var hashA = localRepo.commit(\"test1\", \"test\", \"test@test\");\n            localRepo.push(hashA, author.authenticatedUrl(), \"edit\");\n\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Manually set a label shouldn't affect auto labeling\n            pr.addLabel(\"test2\");\n\n            // Check the status - there should be test1 and test2\n            TestBotRunner.runPeriodicItems(labelBot);\n            assertEquals(Set.of(\"rfr\", \"test2\", \"test1\"), new HashSet<>(pr.store().labelNames()));\n        }\n    }\n\n    @Test\n    void initialUnmatchedLabel(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var labelConfiguration = LabelConfigurationJson.builder()\n                                                           .addMatchers(\"test1\", List.of(Pattern.compile(\"a.txt\")))\n                                                           .addMatchers(\"test2\", List.of(Pattern.compile(\"b.txt\")))\n                                                           .build();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var labelBot = PullRequestBot.newBuilder()\n                                         .repo(author)\n                                         .censusRepo(censusBuilder.build())\n                                         .labelConfiguration(labelConfiguration)\n                                         .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path();\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n\n            var fileA = localRepoFolder.resolve(\"a.txt\");\n            Files.writeString(fileA, \"Hello\");\n            localRepo.add(fileA);\n            var hashA = localRepo.commit(\"test1\", \"test\", \"test@test\");\n            localRepo.push(hashA, author.authenticatedUrl(), \"edit\");\n\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Manually set a label that isn't in the set of automatic ones\n            pr.addLabel(\"test42\");\n\n            // Check the status - the test1 label should have been added\n            TestBotRunner.runPeriodicItems(labelBot);\n            assertEquals(Set.of(\"rfr\", \"test1\", \"test42\"), new HashSet<>(pr.store().labelNames()));\n        }\n    }\n\n    @Test\n    void autoAdjustLabel(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            var labelConfiguration = LabelConfigurationJson.builder()\n                    .addMatchers(\"1\", List.of(Pattern.compile(\"cpp$\")))\n                    .addMatchers(\"2\", List.of(Pattern.compile(\"hpp$\")))\n                    .addMatchers(\"3\", List.of(Pattern.compile(\"txt$\")))\n                    .addGroup(\"group1\", List.of(\"1\", \"2\"))\n                    .addExtra(\"extra\")\n                    .build();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .labelConfiguration(labelConfiguration)\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType(), Path.of(\"test.hpp\"));\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            // The bot should have applied one label automatically\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(Set.of(\"2\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n            assertLastCommentContains(pr, \"The following label will be automatically applied\");\n            assertLastCommentContains(pr, \"`2`\");\n\n            // Add cpp and hpp together should add group label\n            var test1Cpp = localRepo.root().resolve(\"test1.cpp\");\n            try (var output = Files.newBufferedWriter(test1Cpp)) {\n                output.append(\"test\");\n            }\n            localRepo.add(test1Cpp);\n            var test1Hpp = localRepo.root().resolve(\"test1.hpp\");\n            try (var output = Files.newBufferedWriter(test1Hpp)) {\n                output.append(\"test\");\n            }\n            localRepo.add(test1Hpp);\n            var addHash = localRepo.commit(\"add cpp,hpp file\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(addHash, author.authenticatedUrl(), \"edit\", true);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(Set.of(\"group1\", \"2\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n\n            // Add another Cpp file, since \"group1\" label is already added, the bot shouldn't add \"1\" label\n            var test2Cpp = localRepo.root().resolve(\"test2.cpp\");\n            try (var output = Files.newBufferedWriter(test2Cpp)) {\n                output.append(\"test\");\n            }\n            localRepo.add(test2Cpp);\n            addHash = localRepo.commit(\"add cpp2 file\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(addHash, author.authenticatedUrl(), \"edit\", true);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(Set.of(\"group1\", \"2\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n\n            // But user should still be able to add \"1\" label manually\n            pr.addComment(\"/label 1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(Set.of(\"group1\", \"1\", \"2\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n\n            // Simulate force-push.\n            localRepo.checkout(editHash);\n            var test1txt = localRepo.root().resolve(\"test1.txt\");\n            try (var output = Files.newBufferedWriter(test1txt)) {\n                output.append(\"test\");\n            }\n            localRepo.add(test1txt);\n            var forcePushHash = localRepo.commit(\"add txt file\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(forcePushHash, author.authenticatedUrl(), \"edit\", true);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(Set.of(\"group1\", \"1\", \"2\", \"rfr\", \"3\"), new HashSet<>(pr.store().labelNames()));\n        }\n    }\n\n    @Test\n    void autoAdjustLabelWithMerge(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            var labelConfiguration = LabelConfigurationJson.builder()\n                    .addMatchers(\"1\", List.of(Pattern.compile(\"cpp$\")))\n                    .addMatchers(\"2\", List.of(Pattern.compile(\"hpp$\")))\n                    .addMatchers(\"3\", List.of(Pattern.compile(\"txt$\")))\n                    .addGroup(\"group1\", List.of(\"1\", \"2\"))\n                    .addExtra(\"extra\")\n                    .build();\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .labelConfiguration(labelConfiguration)\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType(), Path.of(\"test.hpp\"));\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            // The bot should have applied one label automatically\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(Set.of(\"2\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n            assertLastCommentContains(pr, \"The following label will be automatically applied\");\n            assertLastCommentContains(pr, \"`2`\");\n\n            // Update the target branch\n            localRepo.checkout(masterHash);\n            var txtFile = localRepo.root().resolve(\"unrelated.txt\");\n            Files.writeString(txtFile, \"Hello\");\n            localRepo.add(txtFile);\n            var updatedMasterHash = localRepo.commit(\"add txt file\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(updatedMasterHash, author.authenticatedUrl(), \"master\", true);\n\n            TestBotRunner.runPeriodicItems(prBot);\n            // Change to master branch shouldn't change labels\n            assertEquals(Set.of(\"2\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n\n            // Merge master into edit\n            localRepo.checkout(editHash);\n            localRepo.merge(updatedMasterHash);\n            var mergeHash = localRepo.commit(\"merge master\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            // Add cpp file\n            localRepo.checkout(mergeHash);\n            var cppFile = localRepo.root().resolve(\"test.cpp\");\n            Files.writeString(cppFile, \"Hello cpp\");\n            localRepo.add(cppFile);\n            var updatedEditHash = localRepo.commit(\"add cpp file\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(updatedEditHash, author.authenticatedUrl(), \"edit\", true);\n\n            TestBotRunner.runPeriodicItems(prBot);\n            // The commit brought in by merge shouldn't affect labels, so \"3\" shouldn't be added\n            // After adding cpp file, \"1\" should be added, but \"2\" label already there, so \"1\" will be upgraded to \"group1\"\n            assertEquals(Set.of(\"group1\", \"2\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n\n            // Remove group1 manually\n            pr.addComment(\"/label remove group1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(Set.of(\"2\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n\n            //Add another file trigger label \"1\"\n            var cpp2File = localRepo.root().resolve(\"test2.cpp\");\n            Files.writeString(cpp2File, \"Hello cpp\");\n            localRepo.add(cpp2File);\n            var updated2EditHash = localRepo.commit(\"add test2.cpp file\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(updated2EditHash, author.authenticatedUrl(), \"edit\", true);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertEquals(Set.of(\"1\", \"2\", \"rfr\"), new HashSet<>(pr.store().labelNames()));\n        }\n    }\n\n    @Test\n    void autoLabelAppliesTwoReviewersRule(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer1 = credentials.getHostedRepository();\n            var reviewer2 = credentials.getHostedRepository();\n\n            var labelConfiguration = LabelConfigurationJson.builder()\n                    .addMatchers(\"hotspot\", List.of(Pattern.compile(\"hotspot.txt\")))\n                    .build();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer1.forge().currentUser().id())\n                    .addReviewer(reviewer2.forge().currentUser().id());\n            var labelBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .labelConfiguration(labelConfiguration)\n                    .twoReviewersLabels(Set.of(\"hotspot\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path();\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR matching the automatic \"hotspot\" label\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n\n            var hotspotFile = localRepoFolder.resolve(\"hotspot.txt\");\n            Files.writeString(hotspotFile, \"hotspot\");\n            localRepo.add(hotspotFile);\n            var hotspotHash = localRepo.commit(\"touch hotspot area\", \"test\", \"test@test\");\n            localRepo.push(hotspotHash, author.authenticatedUrl(), \"edit\");\n\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Automatic labeling should apply hotspot and require two reviewers\n            TestBotRunner.runPeriodicItems(labelBot);\n            assertTrue(pr.store().labelNames().contains(\"hotspot\"));\n            assertTrue(pr.store().labelNames().contains(\"rfr\"));\n            assertLastCommentContains(pr, \"The total number of required reviews for this PR has been set to 2 based on the presence of this label: `hotspot`.\");\n            assertLastCommentContains(pr, \"This can be overridden with the `/reviewers` command.\");\n\n            var reviewer1Pr = reviewer1.pullRequest(pr.id());\n            reviewer1Pr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(labelBot);\n\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().body().contains(\"2 reviews required, with at least 1 [Reviewer](https://openjdk.org/bylaws#reviewer), \" +\n                    \"1 [Author](https://openjdk.org/bylaws#author)\"));\n\n            pr.removeLabel(\"hotspot\");\n            TestBotRunner.runPeriodicItems(labelBot);\n            assertTrue(pr.store().body().contains(\"2 reviews required, with at least 1 [Reviewer](https://openjdk.org/bylaws#reviewer), \" +\n                    \"1 [Author](https://openjdk.org/bylaws#author)\"));\n        }\n    }\n\n    @Test\n    void twoReviewersRuleClearedForBackportPR(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory(false)) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var labelConfiguration = LabelConfigurationJson.builder()\n                    .addMatchers(\"hotspot\", List.of(Pattern.compile(\"hotspot.txt\")))\n                    .build();\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .labelConfiguration(labelConfiguration)\n                    .twoReviewersLabels(Set.of(\"hotspot\"))\n                    .issuePRMap(new HashMap<>())\n                    .reviewCleanBackport(true)\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"hotspot.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                    \"\\n\" +\n                    \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"hotspot.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Wrong title\", List.of(\"body\"));\n\n            TestBotRunner.runPeriodicItems(bot);\n            assertLastCommentContains(pr, \"The total number of required reviews for this PR has been set to 2 based on the presence of this label: `hotspot`.\");\n            assertTrue(pr.store().body().contains(\"2 reviews required\"));\n\n            // Correct the title\n            pr.setTitle(\"Backport \" + releaseHash.hex());\n            TestBotRunner.runPeriodicItems(bot);\n            var comments = pr.comments();\n            // Two reviewers requirement should be cleared\n            var twoReviewersClearedComment = comments.get(3).body();\n            assertTrue(twoReviewersClearedComment.contains(\"This is now a backport PR, the extra reviewers requirement has been cleared.\"));\n            assertTrue(pr.store().body().contains(\"1 review required\"));\n            // The bot should reply with a backport message\n            var backportComment = comments.get(4).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", pr.store().title());\n            assertTrue(pr.store().labelNames().contains(\"backport\"));\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n        }\n    }\n\n    @Test\n    void twoReviewersRuleClearedForMergeStylePR(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var labelConfiguration = LabelConfigurationJson.builder()\n                    .addMatchers(\"hotspot\", List.of(Pattern.compile(\"hotspot.txt\")))\n                    .build();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var labelBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .labelConfiguration(labelConfiguration)\n                    .twoReviewersLabels(Set.of(\"hotspot\"))\n                    .reviewMerge(MergePullRequestReviewConfiguration.ALWAYS)\n                    .build();\n\n            var localRepoFolder = tempFolder.path();\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n\n            var hotspotFile = localRepoFolder.resolve(\"hotspot.txt\");\n            Files.writeString(hotspotFile, \"hotspot\");\n            localRepo.add(hotspotFile);\n            var hotspotHash = localRepo.commit(\"touch hotspot area\", \"test\", \"test@test\");\n            localRepo.push(hotspotHash, author.authenticatedUrl(), \"edit\");\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other\",\n                    \"First other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other\", true);\n\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            TestBotRunner.runPeriodicItems(labelBot);\n            assertLastCommentContains(pr, \"The total number of required reviews for this PR has been set to 2 based on the presence of this label: `hotspot`.\");\n            assertTrue(pr.store().body().contains(\"2 reviews required\"));\n\n            // Convert to Merge Style PR\n            pr.setTitle(\"Merge \" + author.name() + \":other\");\n            TestBotRunner.runPeriodicItems(labelBot);\n            assertLastCommentContains(pr, \"This is now a merge PR, the extra reviewers requirement has been cleared.\");\n            assertTrue(pr.store().body().contains(\"1 review required\"));\n\n            // Convert back to normal PR\n            pr.setTitle(\"123: Not a merge PR\");\n            TestBotRunner.runPeriodicItems(labelBot);\n            assertLastCommentContains(pr, \"The total number of required reviews for this PR has been set to 2 based on the presence of this label: `hotspot`.\");\n            assertTrue(pr.store().body().contains(\"2 reviews required\"));\n\n            // Convert to Merge Style PR again\n            pr.setTitle(\"Merge \" + author.name() + \":other\");\n            TestBotRunner.runPeriodicItems(labelBot);\n            assertLastCommentContains(pr, \"This is now a merge PR, the extra reviewers requirement has been cleared.\");\n            assertTrue(pr.store().body().contains(\"1 review required\"));\n\n            // Issue a reviewers comment\n            var reviewPR = reviewer.pullRequest(pr.id());\n            reviewPR.addComment(\"/reviewers 4\");\n            TestBotRunner.runPeriodicItems(labelBot);\n            assertLastCommentContains(pr, \"The total number of required reviews for this PR (including the jcheck configuration and the last /reviewers command) is now set to 4\");\n            assertTrue(pr.store().body().contains(\"4 reviews required\"));\n\n            //Convert back to normal PR\n            pr.setTitle(\"123: Not a merge PR\");\n            TestBotRunner.runPeriodicItems(labelBot);\n            // Shouldn't have the two reviewers comment posted\n            assertLastCommentContains(pr, \"The total number of required reviews for this PR (including the jcheck configuration and the last /reviewers command) is now set to 4\");\n            assertTrue(pr.store().body().contains(\"4 reviews required\"));\n        }\n    }\n\n    @Test\n    void explicitReviewersCommandWinsOverTwoReviewersLabel(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer1 = credentials.getHostedRepository();\n            var reviewer2 = credentials.getHostedRepository();\n\n            var labelConfiguration = LabelConfigurationJson.builder()\n                    .addMatchers(\"hotspot\", List.of(Pattern.compile(\"hotspot.txt\")))\n                    .build();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id())\n                    .addReviewer(reviewer1.forge().currentUser().id())\n                    .addReviewer(reviewer2.forge().currentUser().id());\n            var labelBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .labelConfiguration(labelConfiguration)\n                    .twoReviewersLabels(Set.of(\"hotspot\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path();\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR matching the automatic \"hotspot\" label\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var hotspotFile = localRepoFolder.resolve(\"hotspot.txt\");\n            Files.writeString(hotspotFile, \"hotspot\");\n            localRepo.add(hotspotFile);\n            var hotspotHash = localRepo.commit(\"touch hotspot area\", \"test\", \"test@test\");\n            localRepo.push(hotspotHash, author.authenticatedUrl(), \"edit\");\n\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Explicit command immediately after PR creation should win\n            var reviewer1Pr = reviewer1.pullRequest(pr.id());\n            reviewer1Pr.addComment(\"/reviewers 1 reviewer\");\n\n            TestBotRunner.runPeriodicItems(labelBot);\n            // First round handles command/labeler ordering; second round verifies resulting policy state\n            TestBotRunner.runPeriodicItems(labelBot);\n            assertTrue(pr.store().labelNames().contains(\"hotspot\"));\n            assertTrue(pr.store().body().contains(\"1 review required\"));\n\n            reviewer1Pr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(labelBot);\n\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/MergeTests.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.CheckStatus;\nimport org.openjdk.skara.forge.Review;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.process.Process;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.*;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.junit.jupiter.api.Assumptions.assumeTrue;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\nimport static org.openjdk.skara.forge.CheckStatus.SUCCESS;\n\nclass MergeTests {\n    @Test\n    void branchMerge(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other_/-1.2\",\n                                                                \"First other_/-1.2\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other_/-1.2\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other_/-1.2\",\n                                                                \"Second other_/-1.2\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other_/-1.2\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.merge(otherHash2);\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other_/-1.2\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // There is a merge commit at HEAD, but the merge commit is empty\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed);\n\n            // The change should now be present on the master branch\n            var pushedRepoFolder = tempFolder.path().resolve(\"pushedrepo\");\n            var pushedRepo = Repository.materialize(pushedRepoFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            // The commits from the \"other\" branch should be preserved and not squashed (but not the merge commit)\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            Set<Hash> commits;\n            try (var tempCommits = pushedRepo.commits(masterHash.hex() + \"..\" + headHash.hex())) {\n                commits = tempCommits.stream()\n                        .map(Commit::hash)\n                        .collect(Collectors.toSet());\n            }\n            assertTrue(commits.contains(otherHash1));\n            assertTrue(commits.contains(otherHash2));\n            assertFalse(commits.contains(mergeHash));\n\n            // Author and committer should updated in the merge commit\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n            assertEquals(\"Merge \" + author.name() + \":other_/-1.2\", headCommit.message().get(0));\n            assertEquals(\"Generated Committer 1\", headCommit.author().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.author().email());\n            assertEquals(\"Generated Committer 1\", headCommit.committer().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.committer().email());\n        }\n    }\n\n    @Test\n    void branchMergeWithReviewMergeRequest(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build())\n                    .reviewMerge(MergePullRequestReviewConfiguration.ALWAYS).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other_/-1.2\",\n                    \"First other_/-1.2\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other_/-1.2\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other_/-1.2\",\n                    \"Second other_/-1.2\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other_/-1.2\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.merge(otherHash2);\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other_/-1.2\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // pr should not be ready, because review needed for merge pull requests\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().body().contains(\"- [ ] Change must be properly reviewed\"));\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().body().contains(\"- [x] Change must be properly reviewed\"));\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var pushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .count();\n            assertEquals(1, pushed);\n\n            // The change should now be present on the master branch\n            var pushedRepoFolder = tempFolder.path().resolve(\"pushedrepo\");\n            var pushedRepo = Repository.materialize(pushedRepoFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            // The commits from the \"other\" branch should be preserved and not squashed (but not the merge commit)\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            Set<Hash> commits;\n            try (var tempCommits = pushedRepo.commits(masterHash.hex() + \"..\" + headHash.hex())) {\n                commits = tempCommits.stream()\n                        .map(Commit::hash)\n                        .collect(Collectors.toSet());\n            }\n            assertTrue(commits.contains(otherHash1));\n            assertTrue(commits.contains(otherHash2));\n            assertFalse(commits.contains(mergeHash));\n\n            // Author and committer should updated in the merge commit\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n            assertEquals(\"Merge \" + author.name() + \":other_/-1.2\", headCommit.message().get(0));\n            assertEquals(\"Generated Committer 1\", headCommit.author().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.author().email());\n            assertEquals(\"Generated Committer 1\", headCommit.committer().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.committer().email());\n            assertTrue(String.join(\"\", headCommit.message())\n                            .matches(\".*Reviewed-by: integrationreviewer2$\"),\n                    String.join(\"\", headCommit.message()));\n        }\n    }\n\n    @Test\n    void branchMergeWithReviewersCommand(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other_/-1.2\",\n                    \"First other_/-1.2\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other_/-1.2\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other_/-1.2\",\n                    \"Second other_/-1.2\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other_/-1.2\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.merge(otherHash2);\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other_/-1.2\");\n\n            pr.addComment(\"/reviewers 1\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().body().contains(\"[ ] Change must be properly reviewed (1 review required, with at least 1 [Reviewer](https://openjdk.org/bylaws#reviewer))\"));\n\n            // Approve it\n            var reviewerPr = integrator.pullRequest(pr.id());\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"LGTM\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().body().contains(\"[x] Change must be properly reviewed (1 review required, with at least 1 [Reviewer](https://openjdk.org/bylaws#reviewer))\"));\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var pushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .count();\n            assertEquals(1, pushed);\n        }\n    }\n\n\n    @Test\n    void runJCheckTwiceInMergePR(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator1 = credentials.getHostedRepository();\n            var integrator2 = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator1.forge().currentUser().id())\n                    .addReviewer(integrator2.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator1).censusRepo(censusBuilder.build())\n                    .reviewMerge(MergePullRequestReviewConfiguration.JCHECK).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n\n            var checkConf = localRepoFolder.resolve(\".jcheck/conf\");\n            try (var output = Files.newBufferedWriter(checkConf)) {\n                output.append(\"[general]\\n\");\n                output.append(\"project=test\\n\");\n                output.append(\"jbs=tstprj\\n\");\n                output.append(\"\\n\");\n                output.append(\"[checks]\\n\");\n                output.append(\"error=\");\n                output.append(String.join(\",\", Set.of(\"author\", \"reviewers\", \"whitespace\")));\n                output.append(\"\\n\\n\");\n                output.append(\"[census]\\n\");\n                output.append(\"version=0\\n\");\n                output.append(\"domain=openjdk.org\\n\");\n                output.append(\"\\n\");\n                output.append(\"[checks \\\"whitespace\\\"]\\n\");\n                output.append(\"files=.*\\\\.txt\\n\");\n                output.append(\"\\n\");\n                output.append(\"[checks \\\"reviewers\\\"]\\n\");\n                output.append(\"reviewers=2\\n\");\n                output.append(\"merge=check\");\n            }\n            localRepo.add(checkConf);\n            var otherHash1 = localRepo.commit(\"add conf to master\", \"testauthor\", \"ta@none.none\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other_/-1.2\");\n\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other_/-1.2\",\n                    \"Second other_/-1.2\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other_/-1.2\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.merge(otherHash2);\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other_/-1.2\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // pr should not be ready, because JCheck conf updated in source branch\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().body().contains(\"Too few reviewers with at least role reviewer found (have 0, need at least 2) (failed with updated jcheck configuration in pull request)\"));\n\n            // Approve it as another user\n            var approvalPr1 = integrator1.pullRequest(pr.id());\n            approvalPr1.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            assertTrue(pr.store().body().contains(\"Too few reviewers with at least role reviewer found (have 1, need at least 2)\"));\n\n            var approvalPr2 = integrator2.pullRequest(pr.id());\n            approvalPr2.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n            assertFalse(pr.store().body().contains(\"Too few reviewers with at least role reviewer found\"));\n        }\n    }\n\n    @Test\n    void mergeAllowed(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder()\n                                         .repo(integrator)\n                                         .censusRepo(censusBuilder.build())\n                                         .integrators(Set.of(author.forge().currentUser().username()))\n                                         .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other\",\n                                                                 \"First other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other\",\n                                                                 \"Second other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.merge(otherHash2);\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            assertLastCommentContains(pr, \"Pushed as commit\");\n        }\n    }\n\n    @Test\n    void mergeDisallowed(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder()\n                                         .repo(integrator)\n                                         .censusRepo(censusBuilder.build())\n                                         .integrators(Set.of(integrator.forge().currentUser().username()))\n                                         .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other\",\n                                                                 \"First other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other\",\n                                                                 \"Second other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.merge(otherHash2);\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with a failure message\n            assertLastCommentContains(pr, \"Your integration request cannot be fulfilled at this time\");\n        }\n    }\n\n    @Test\n    void hashMerge(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other\",\n                                                                 \"First other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other\",\n                                                                 \"Second other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.merge(otherHash2);\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + otherHash2.hex());\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed);\n\n            // The change should now be present on the master branch\n            var pushedRepoFolder = tempFolder.path().resolve(\"pushedrepo\");\n            var pushedRepo = Repository.materialize(pushedRepoFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            // The commits from the \"other\" branch should be preserved and not squashed (but not the merge commit)\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            Set<Hash> commits;\n            try (var tempCommits = pushedRepo.commits(masterHash.hex() + \"..\" + headHash.hex())) {\n                commits = tempCommits.stream()\n                                     .map(Commit::hash)\n                                     .collect(Collectors.toSet());\n            }\n            assertTrue(commits.contains(otherHash1));\n            assertTrue(commits.contains(otherHash2));\n            assertFalse(commits.contains(mergeHash));\n\n            // Author and committer should be updated in the merge commit\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n            assertEquals(\"Merge \" + otherHash2.hex(), headCommit.message().get(0));\n            assertEquals(\"Generated Committer 1\", headCommit.author().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.author().email());\n            assertEquals(\"Generated Committer 1\", headCommit.committer().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.committer().email());\n        }\n    }\n\n    @Test\n    void hashMergeExisting(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other\",\n                                                                 \"First other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other\",\n                                                                 \"Second other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other\");\n\n            // Push the new commits to master and then return to the original one\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"master\");\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.merge(otherHash2);\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + otherHash2.hex());\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with a failure message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"did not complete successfully\"))\n                          .count();\n            assertEquals(1, error, () -> pr.comments().stream().map(Comment::body).collect(Collectors.joining(\"\\n\\n\")));\n\n            var check = pr.checks(mergeHash).get(\"jcheck\");\n            assertEquals(\"- A merge PR must contain at least one commit from the source branch that is not already present in the target.\", check.summary().orElseThrow());\n        }\n    }\n\n    @Test\n    void branchMergeRestrictedMessage(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType(),\n                                                     Path.of(\"appendable.txt\"), Set.of(\"merge\"), \"1.0\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other\",\n                                                                 \"First other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other\",\n                                                                 \"Second other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.merge(otherHash2);\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            // The change should now be present on the master branch\n            var pushedRepoFolder = tempFolder.path().resolve(\"pushedrepo\");\n            var pushedRepo = Repository.materialize(pushedRepoFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            // The commits from the \"other\" branch should be preserved and not squashed (but not the merge commit)\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            Set<Hash> commits;\n            try (var tempCommits = pushedRepo.commits(masterHash.hex() + \"..\" + headHash.hex())) {\n                commits = tempCommits.stream()\n                                     .map(Commit::hash)\n                                     .collect(Collectors.toSet());\n            }\n            assertTrue(commits.contains(otherHash1));\n            assertTrue(commits.contains(otherHash2));\n            assertFalse(commits.contains(mergeHash));\n\n            // The commit message should be just \"Merge\"\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n            assertEquals(\"Merge\", headCommit.message().get(0));\n            assertEquals(\"Generated Committer 1\", headCommit.author().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.author().email());\n            assertEquals(\"Generated Committer 1\", headCommit.committer().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.committer().email());\n        }\n    }\n\n    @Test\n    void branchMergeShortName(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other\",\n                                                                 \"First other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other\",\n                                                                 \"Second other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            localRepo.merge(otherHash2);\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge other\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed);\n\n            // The change should now be present on the master branch\n            var pushedRepoFolder = tempFolder.path().resolve(\"pushedrepo\");\n            var pushedRepo = Repository.materialize(pushedRepoFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            // The commits from the \"other\" branch should be preserved and not squashed (but not the merge commit)\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            Set<Hash> commits;\n            try (var tempCommits = pushedRepo.commits(masterHash.hex() + \"..\" + headHash.hex())) {\n                commits = tempCommits.stream()\n                                     .map(Commit::hash)\n                                     .collect(Collectors.toSet());\n            }\n            assertTrue(commits.contains(otherHash1));\n            assertTrue(commits.contains(otherHash2));\n            assertFalse(commits.contains(mergeHash));\n\n            // Author and committer should updated in the merge commit\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n            assertEquals(\"Generated Committer 1\", headCommit.author().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.author().email());\n            assertEquals(\"Generated Committer 1\", headCommit.committer().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.committer().email());\n        }\n    }\n\n    @Test\n    void tagMerge(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other\",\n                                                                 \"First other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other\",\n                                                                 \"Second other\\n\\nReviewed-by: integrationreviewer2\");\n            var tag = localRepo.tag(otherHash2, \"othertag\", \"Tagging other\", \"tagger\", \"tagger@one\");\n            var tagHash = localRepo.lookup(tag).orElseThrow().hash();\n            localRepo.push(tagHash, author.authenticatedUrl(), \"refs/tags/othertag\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            localRepo.merge(otherHash2);\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge othertag\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed);\n\n            // The change should now be present on the master branch\n            var pushedRepoFolder = tempFolder.path().resolve(\"pushedrepo\");\n            var pushedRepo = Repository.materialize(pushedRepoFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            // The commits from the \"other\" branch should be preserved and not squashed (but not the merge commit)\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            Set<Hash> commits;\n            try (var tempCommits = pushedRepo.commits(masterHash.hex() + \"..\" + headHash.hex())) {\n                commits = tempCommits.stream()\n                                     .map(Commit::hash)\n                                     .collect(Collectors.toSet());\n            }\n            assertTrue(commits.contains(otherHash1));\n            assertTrue(commits.contains(otherHash2));\n            assertFalse(commits.contains(mergeHash));\n\n            // Author and committer should updated in the merge commit\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n            assertEquals(\"Generated Committer 1\", headCommit.author().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.author().email());\n            assertEquals(\"Generated Committer 1\", headCommit.committer().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.committer().email());\n        }\n    }\n\n    @Test\n    void branchMergeRebase(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other\",\n                                                                 \"First other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other\",\n                                                                 \"Second other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            localRepo.merge(otherHash2);\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push something new to master\n            localRepo.checkout(updatedMaster, true);\n            var newMaster = Files.writeString(localRepo.root().resolve(\"newmaster.txt\"), \"New on master\");\n            localRepo.add(newMaster);\n            var newMasterHash = localRepo.commit(\"New commit on master\", \"some\", \"some@one\");\n            localRepo.push(newMasterHash, author.authenticatedUrl(), \"master\");\n\n            // Let the bot notice\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed, () -> pr.comments().stream().map(Comment::body).collect(Collectors.joining(\"\\n\\n\")));\n\n            // The change should now be present on the master branch\n            var pushedRepoFolder = tempFolder.path().resolve(\"pushedrepo\");\n            var pushedRepo = Repository.materialize(pushedRepoFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            // The commits from the \"other\" branch should be preserved and not squashed (but not the merge commit)\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            Set<Hash> commits;\n            try (var tempCommits = pushedRepo.commits(masterHash.hex() + \"..\" + headHash.hex())) {\n                commits = tempCommits.stream()\n                        .map(Commit::hash)\n                        .collect(Collectors.toSet());\n            }\n            assertTrue(commits.contains(otherHash1));\n            assertTrue(commits.contains(otherHash2));\n            assertFalse(commits.contains(mergeHash));\n\n            // Author and committer should updated in the merge commit\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n            assertEquals(\"Generated Committer 1\", headCommit.author().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.author().email());\n            assertEquals(\"Generated Committer 1\", headCommit.committer().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.committer().email());\n        }\n    }\n\n    @Test\n    void branchMergeAdditionalCommits(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other\",\n                                                                 \"First other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other\",\n                                                                 \"Second other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            localRepo.merge(otherHash2);\n            var mergeHash = localRepo.commit(\"Our own merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push something new to master\n            localRepo.checkout(updatedMaster, true);\n            var newMaster = Files.writeString(localRepo.root().resolve(\"newmaster.txt\"), \"New on master\");\n            localRepo.add(newMaster);\n            var newMasterHash = localRepo.commit(\"New commit on master\", \"some\", \"some@one\");\n            localRepo.push(newMasterHash, author.authenticatedUrl(), \"master\");\n\n            // Let the bot notice\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Add another commit on top of the merge commit\n            localRepo.checkout(mergeHash, true);\n            var extraHash = CheckableRepository.appendAndCommit(localRepo, \"Fixing up stuff after merge\");\n            localRepo.push(extraHash, author.authenticatedUrl(), \"edit\");\n\n            // Let the bot notice again\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Merge the latest from master\n            localRepo.merge(newMasterHash);\n            var latestMergeHash = localRepo.commit(\"Our to be squashed merge commit\", \"some\", \"some@one\");\n            localRepo.push(latestMergeHash, author.authenticatedUrl(), \"edit\");\n\n            // Let the bot notice again\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed, () -> pr.comments().stream().map(Comment::body).collect(Collectors.joining(\"\\n\\n\")));\n\n            // The change should now be present on the master branch\n            var pushedRepoFolder = tempFolder.path().resolve(\"pushedrepo\");\n            var pushedRepo = Repository.materialize(pushedRepoFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            // The commits from the \"other\" branch should be preserved and not squashed (but not the merge commit)\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            String commits;\n            try (var tempCommits = pushedRepo.commits(masterHash.hex() + \"..\" + headHash.hex())) {\n                commits = tempCommits.stream()\n                                     .map(c -> c.hash().hex() + \":\" + c.message().get(0))\n                                     .collect(Collectors.joining(\",\"));\n            }\n            assertTrue(commits.contains(otherHash1.hex() + \":First other\"));\n            assertTrue(commits.contains(otherHash2.hex() + \":Second other\"));\n            assertFalse(commits.contains(\"Our own merge commit\"));\n\n            // Author and committer should updated in the merge commit\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n            assertEquals(\"Generated Committer 1\", headCommit.author().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.author().email());\n            assertEquals(\"Generated Committer 1\", headCommit.committer().name());\n            assertEquals(\"integrationcommitter1@openjdk.org\", headCommit.committer().email());\n\n            // The latest content from the source and the updated master should be present\n            assertEquals(\"New on master\", Files.readString(pushedRepoFolder.resolve(\"newmaster.txt\")));\n            assertEquals(\"Unrelated\", Files.readString(pushedRepoFolder.resolve(\"unrelated.txt\")));\n        }\n    }\n\n    @Test\n    void invalidMergeCommit(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change in another branch\n            var otherHash = CheckableRepository.appendAndCommit(localRepo, \"Change in other\",\n                                                                \"Other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash, author.authenticatedUrl(), \"other\", true);\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.merge(otherHash);\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot will create a proper merge commit\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed, () -> pr.comments().stream().map(Comment::body).collect(Collectors.joining(\"\\n\\n\")));\n\n            // The change should now be present with correct parents on the master branch\n            var pushedRepoFolder = tempFolder.path().resolve(\"pushedrepo\");\n            var pushedRepo = Repository.materialize(pushedRepoFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            var head = pushedRepo.commitMetadata(\"HEAD^!\").get(0);\n            assertEquals(2, head.parents().size());\n            assertEquals(masterHash, head.parents().get(0));\n            assertEquals(otherHash, head.parents().get(1));\n        }\n    }\n\n    @Test\n    void invalidSourceRepo(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change in another branch\n            var otherHash = CheckableRepository.appendAndCommit(localRepo, \"Change in other\",\n                                                                \"Other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash, author.authenticatedUrl(), \"other\", true);\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            localRepo.merge(otherHash);\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + TestHost.NON_EXISTING_REPO + \":other\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with a failure message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"did not complete successfully\"))\n                          .count();\n            assertEquals(1, error, () -> pr.comments().stream().map(Comment::body).collect(Collectors.joining(\"\\n\\n\")));\n\n            var check = pr.checks(mergeHash).get(\"jcheck\");\n            assertEquals(\"- Could not find project `\" + TestHost.NON_EXISTING_REPO + \"` - check that it is correct.\", check.summary().orElseThrow());\n        }\n    }\n\n    @Test\n    void invalidSourceBranch(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change in another branch\n            var otherHash = CheckableRepository.appendAndCommit(localRepo, \"Change in other\",\n                                                                \"Other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash, author.authenticatedUrl(), \"other\", true);\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            localRepo.merge(otherHash);\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":otherxyz\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with a failure message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"did not complete successfully\"))\n                          .count();\n            assertEquals(1, error, () -> pr.comments().stream().map(Comment::body).collect(Collectors.joining(\"\\n\\n\")));\n\n            var check = pr.checks(mergeHash).get(\"jcheck\");\n            assertEquals(\"- Could not find the branch or tag `otherxyz` in the project `\" + author.name() + \"` - check that it is correct.\", check.summary().orElseThrow());\n        }\n    }\n\n    @Test\n    void inferredSourceProject(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change in another branch\n            var otherHash = CheckableRepository.appendAndCommit(localRepo, \"Change in other\",\n                                                                \"Other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash, author.authenticatedUrl(), \"other\", true);\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            localRepo.merge(otherHash);\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + TestHost.NON_EXISTING_REPO);\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with a failure message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"did not complete successfully\"))\n                          .count();\n            assertEquals(1, error, () -> pr.comments().stream().map(Comment::body).collect(Collectors.joining(\"\\n\\n\")));\n\n            var check = pr.checks(mergeHash).get(\"jcheck\");\n            assertEquals(\"- Could not find project `\" + TestHost.NON_EXISTING_REPO + \"` - check that it is correct.\", check.summary().orElseThrow());\n        }\n    }\n\n    @Test\n    void wrongSourceBranch(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change in another branch\n            var other1Hash = CheckableRepository.appendAndCommit(localRepo, \"Change in other1\",\n                                                                \"Other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(other1Hash, author.authenticatedUrl(), \"other1\", true);\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make yet another change in another branch\n            var other2Hash = CheckableRepository.appendAndCommit(localRepo, \"Change in other2\",\n                                                                \"Unrelated\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(other2Hash, author.authenticatedUrl(), \"other2\", true);\n\n            // Make a change with a corresponding PR\n            localRepo.checkout(masterHash, true);\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            localRepo.merge(other1Hash, \"ours\");\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other2\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with a failure message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"did not complete successfully\"))\n                          .count();\n            assertEquals(1, error, () -> pr.comments().stream().map(Comment::body).collect(Collectors.joining(\"\\n\\n\")));\n\n            var check = pr.checks(mergeHash).get(\"jcheck\");\n            assertEquals(\"- A merge PR must contain at least one commit from the source branch that is not already present in the target.\", check.summary().orElseThrow());\n        }\n    }\n\n    @Test\n    void invalidAuthor(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change in another branch\n            var otherHash = CheckableRepository.appendAndCommit(localRepo, \"Change in other\",\n                                                                \"Other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash, author.authenticatedUrl(), \"other\", true);\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            localRepo.merge(otherHash);\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with a need for sponsor\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"Afterwards, your sponsor types `/sponsor`\"))\n                          .count();\n            assertEquals(1, error, () -> pr.comments().stream().map(Comment::body).collect(Collectors.joining(\"\\n\\n\")));\n        }\n    }\n\n    @Test\n    void unrelatedHistory(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            // Need to force merge unrelated histories\n            assumeTrue(author.repositoryType().equals(VCS.GIT));\n\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make an unrelated change in another branch\n            var unrelatedRepoFolder = tempFolder.path().resolve(\"unrelated\");\n            var unrelatedRepo = CheckableRepository.init(unrelatedRepoFolder, author.repositoryType(), Path.of(\"anotherfile.txt\"));\n            unrelatedRepo.amend(\"Unrelated initial commit\\n\\nReviewed-by: integrationreviewer2\", \"some\", \"one@mail\");\n            var otherHash = CheckableRepository.appendAndCommit(unrelatedRepo, \"Change in other\",\n                                                                \"Other\\n\\nReviewed-by: integrationreviewer2\");\n            unrelatedRepo.push(otherHash, author.authenticatedUrl(), \"other\", true);\n            localRepo.fetch(author.authenticatedUrl(), \"other\").orElseThrow();\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            try (var p = Process.command(\"git\", \"merge\", \"--no-commit\", \"--allow-unrelated-histories\", \"-s\", \"ours\", otherHash.hex())\n                    .workdir(localRepo.root())\n                    .environ(\"GIT_AUTHOR_NAME\", \"some\")\n                    .environ(\"GIT_AUTHOR_EMAIL\", \"some@one\")\n                    .environ(\"GIT_COMMITTER_NAME\", \"another\")\n                    .environ(\"GIT_COMMITTER_EMAIL\", \"another@one\")\n                    .execute()) {\n                p.check();\n            }\n\n            //localRepo.merge(otherHash);\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with a failure message\n            var error = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"did not complete successfully\"))\n                    .count();\n            assertEquals(1, error, () -> pr.comments().stream().map(Comment::body).collect(Collectors.joining(\"\\n\\n\")));\n\n            var check = pr.checks(mergeHash).get(\"jcheck\");\n            assertEquals(\"- The target and the source branches do not share common history - cannot merge them.\", check.summary().orElseThrow());\n        }\n    }\n\n    @Test\n    void invalidSyntax(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType(), Path.of(\"appendable.txt\"), Set.of(\"merge\"), \"1.0\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change in another branch\n            var otherHash = CheckableRepository.appendAndCommit(localRepo, \"Change in other\",\n                                                                \"Other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash, author.authenticatedUrl(), \"other\", true);\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            localRepo.merge(otherHash);\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge this or that\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with a failure message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"did not complete successfully\"))\n                          .count();\n            assertEquals(1, error, () -> pr.comments().stream().map(Comment::body).collect(Collectors.joining(\"\\n\\n\")));\n\n            var check = pr.checks(mergeHash).get(\"jcheck\");\n            assertEquals(\"- Could not determine the source for this merge. A Merge PR title must be specified in the format: `^Merge ([-/.\\\\w:+]+)$` to allow verification of the merge contents.\", check.summary().orElseThrow());\n        }\n    }\n\n    @Test\n    void branchWithPlus(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType(), Path.of(\"appendable.txt\"), Set.of(\"merge\"), \"1.0\");\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change in another branch\n            var otherHash = CheckableRepository.appendAndCommit(localRepo, \"Change in other\",\n                                                                \"Other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash, author.authenticatedUrl(), \"branch-a+b\", true);\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            localRepo.merge(otherHash);\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge branch-a+b\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // should be successful\n            var check = pr.checks(mergeHash).get(\"jcheck\");\n            assertSame(SUCCESS, check.status());\n        }\n    }\n\n    @Test\n    void foreignCommitWarning(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other\",\n                                                                 \"First other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other\",\n                                                                 \"Second other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit( \"Unrelated\", \"some\", \"some@one\");\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            // Go back to the original master again\n            localRepo.checkout(masterHash, true);\n            var editChange = Files.writeString(localRepo.root().resolve(\"edit.txt\"), \"Edit\");\n            localRepo.add(editChange);\n            var editHash = localRepo.commit( \"Edit\", \"some\", \"some@one\");\n\n            // Merge the latest commit from master\n            localRepo.merge(updatedMaster);\n            var masterMergeHash = localRepo.commit(\"Master merge commit\", \"some\", \"some@one\");\n            localRepo.push(masterMergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"1234: A change\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Merging latest master should not trigger a warning\n            assertEquals(1, pr.comments().size());\n\n            localRepo.merge(otherHash2);\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // There should be a warning\n            assertLastCommentContains(pr, \"This pull request contains merges that bring in commits not present\");\n        }\n    }\n\n    @Test\n    void noMergeCommitAtHead(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other_/-1.2\",\n                    \"First other_/-1.2\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other_/-1.2\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other_/-1.2\",\n                    \"Second other_/-1.2\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other_/-1.2\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            var pr = credentials.createPullRequest(author, \"master\", \"other_/-1.2\", \"Merge \" + author.name() + \":other_/-1.2\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n        }\n    }\n\n    @Test\n    void MergeCommitWithResolutionAtHead(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other_/-1.2\",\n                    \"First other_/-1.2\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other_/-1.2\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other_/-1.2\",\n                    \"Second other_/-1.2\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other_/-1.2\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // update\n            var defaultAppendable = Files.readString(localRepo.root().resolve(\"appendable.txt\"));\n            var newAppendable = \"11111\\n\" + defaultAppendable;\n            Files.writeString(localRepo.root().resolve(\"appendable.txt\"), newAppendable);\n            localRepo.add(localRepo.root().resolve(\"appendable.txt\"));\n            localRepo.commit(\"updated\", \"test\", \"test@test.com\");\n\n            localRepo.merge(otherHash2);\n            var mergeHash = localRepo.commit(\"Merge commit\\n\\n This is Body\", \"some\", \"some@one\");\n\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other_/-1.2\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // There is a merge commit at HEAD and the merge commit is not empty\n            assertFalse(pr.store().labelNames().contains(\"clean\"));\n        }\n    }\n\n    @Test\n    void EmptyMergeCommitAtHead(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var feature = localRepo.branch(masterHash, \"feature\");\n            localRepo.checkout(feature);\n            var featureHash = CheckableRepository.appendAndCommit(localRepo);\n\n            localRepo.checkout(masterHash);\n            localRepo.merge(featureHash, Repository.FastForward.DISABLE);\n            var mergeHash = localRepo.commit(\"merged\\n\\n This is Body\", \"xxx\", \"xxx@gmail.com\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other_/-1.2\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // There is a merge commit at HEAD and the merge commit is not empty\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n        }\n    }\n\n    @Test\n    void mergeSourceInvalid(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository(\"openjdk/jdk\");\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder()\n                    .repo(author)\n                    .censusRepo(censusBuilder.build())\n                    .mergeSources(Set.of(\"openjdk/playground\", \"openjdk/skara\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge openjdk/test:other_/-1.2\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertEquals(2, pr.comments().size());\n            assertLastCommentContains(pr, \"can not be source repo for merge-style pull requests in this repository.\");\n        }\n    }\n\n    @Test\n    void JCheckFailInOneOfTheCommits(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).jcheckMerge(true).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other_/-1.2\",\n                    \"First other_/-1.2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other_/-1.2\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other_/-1.2\\n\\r\",\n                    \"Second other_/-1.2\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other_/-1.2\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.merge(otherHash2);\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other_/-1.2\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // There is a merge commit at HEAD, but the merge commit is empty\n            assertTrue(pr.store().labelNames().contains(\"clean\"));\n\n            // Push it\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should not push the commit\n            var pushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .count();\n            assertEquals(0, pushed);\n\n            assertTrue(pr.store().body().contains(\"Too few reviewers with at least role reviewer found (have 0, need at least 1) (in commit `\" + otherHash1.hex() + \"` with target configuration)\"));\n            assertTrue(pr.store().body().contains(\"Whitespace errors (in commit `\" + otherHash2.hex() + \"` with target configuration)\"));\n        }\n    }\n\n    @Test\n    void JCheckConfInvalidInOneOfTheCommits(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).jcheckMerge(true).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other_/-1.2\",\n                    \"First other_/-1.2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other_/-1.2\", true);\n\n            var confPath = localRepoFolder.resolve(\".jcheck/conf\");\n            Files.writeString(confPath, \"Hello there!\");\n            localRepo.add(confPath);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other_/-1.2\\n\\r\",\n                    \"Second other_/-1.2\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other_/-1.2\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.merge(otherHash2);\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other_/-1.2\");\n\n            // Let the bot check the status\n            assertThrows(RuntimeException.class, () -> TestBotRunner.runPeriodicItems(mergeBot));\n\n            var checks = pr.checks(mergeHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"jcheck\");\n            assertEquals(CheckStatus.FAILURE, check.status());\n            assertEquals(\"line 0: entry must be of form 'key = value'\", check.summary().get());\n            assertEquals(\"Exception occurred during merge jcheck with target conf in commit \" + otherHash2.hex() + \" - the operation will be retried\",\n                    check.title().get());\n        }\n    }\n\n    @Test\n    void noSecondParentSpecified(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addCommitter(author.forge().currentUser().id())\n                    .addReviewer(integrator.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder()\n                                         .repo(integrator)\n                                         .censusRepo(censusBuilder.build())\n                                         .reviewMerge(MergePullRequestReviewConfiguration.ALWAYS)\n                                         .jcheckMerge(true)\n                                         .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other\",\n                    \"First other\\n\\nReviewed-by: integrationreviewer2\");\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other\",\n                    \"Second other\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            var pr = credentials.createPullRequest(author, \"master\", \"other\", \"Merge\");\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The PR title should have been updated\n            assertEquals(\"Merge \" + otherHash2.hex(), pr.title());\n            assertLastCommentContains(pr,\n                    \"The second parent of the resulting merge commit from this pull request will be set to `\" +\n                    otherHash2.hex() + \"`\");\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/OpenCommandTests.java",
    "content": "/*\n * Copyright (c) 2021, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\npublic class OpenCommandTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n\n            var prBot = PullRequestBot.newBuilder()\n                                      .repo(integrator)\n                                      .censusRepo(censusBuilder.build())\n                                      .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Close the PR\n            pr.setState(Issue.State.CLOSED);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertSame(pr.store().state(), Issue.State.CLOSED);\n\n            // Issue the \"/open\" PR command, should make the PR open again\n            pr.addComment(\"/open\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertSame(pr.store().state(), Issue.State.OPEN);\n            assertLastCommentContains(pr, \"This pull request is now open\");\n        }\n    }\n\n    @Test\n    void openCommandOnlyAllowedByAuthor(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var other = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addCommitter(other.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n\n            var prBot = PullRequestBot.newBuilder()\n                                      .repo(integrator)\n                                      .censusRepo(censusBuilder.build())\n                                      .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Close the PR\n            pr.setState(Issue.State.CLOSED);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertSame(pr.store().state(), Issue.State.CLOSED);\n\n            // Try to issue the \"/open\" PR command, should not work\n            var prAsOther = other.pullRequest(pr.id());\n            prAsOther.addComment(\"/open\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertSame(pr.store().state(), Issue.State.CLOSED);\n            assertLastCommentContains(prAsOther, \"Only the pull request author can set the pull request state to \\\"open\\\"\");\n        }\n    }\n\n    @Test\n    void openCommandOnlyAllowedOnClosedPullRequest(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n\n            var prBot = PullRequestBot.newBuilder()\n                                      .repo(integrator)\n                                      .censusRepo(censusBuilder.build())\n                                      .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Try to issue the \"/open\" PR command, should not work\n            assertSame(pr.store().state(), Issue.State.OPEN);\n            pr.addComment(\"/open\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertSame(pr.store().state(), Issue.State.OPEN);\n            assertLastCommentContains(pr, \"This pull request is already open\");\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/PreIntegrateTests.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\npublic class PreIntegrateTests {\n    @Test\n    void integrateFollowup(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var seedFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder()\n                                         .repo(integrator)\n                                         .censusRepo(censusBuilder.build())\n                                         .seedStorage(seedFolder.path())\n                                         .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"First PR\", \"Base change\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // The bot should reply with integration message\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertLastCommentContains(pr, \"To integrate this PR with the above commit message to the `master` branch\");\n\n            // Simulate population of the pr branch\n            localRepo.push(editHash, author.authenticatedUrl(), PreIntegrations.preIntegrateBranch(pr), true);\n\n            // Create follow-up work\n            var followUp = CheckableRepository.appendAndCommit(localRepo, \"Follow-up work\", \"Follow-up change\");\n            localRepo.push(followUp, author.authenticatedUrl(), \"followup\", true);\n            var followUpPr = credentials.createPullRequest(author, PreIntegrations.preIntegrateBranch(pr), \"followup\", \"This is another pull request\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Approve it as another user\n            var approvalFollowUpPr = integrator.pullRequest(followUpPr.id());\n            approvalFollowUpPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // The bot should add an integration blocker message\n            assertTrue(followUpPr.store().body().contains(\"Integration blocker\"));\n            assertTrue(followUpPr.store().body().contains(\"Dependency #\" + pr.id() + \" must be integrated\"));\n\n            // Try to integrate it\n            followUpPr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertLastCommentContains(followUpPr, \"This pull request has not yet been marked as ready for integration\");\n\n            // Push something unrelated to the target\n            localRepo.checkout(masterHash, true);\n            var unrelatedFile = localRepo.root().resolve(\"unrelated.txt\");\n            Files.writeString(unrelatedFile, \"Other things happens in master\");\n            localRepo.add(unrelatedFile);\n            var newMasterHash = localRepo.commit(\"Unrelated change\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(newMasterHash, author.authenticatedUrl(), \"master\");\n\n            // Now integrate the first one\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            // The notifier will now retarget the follow up PR, simulate this\n            followUpPr.setTargetRef(\"master\");\n\n            // The second should now become ready\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertFalse(followUpPr.store().body().contains(\"Integration blocker\"));\n            assertTrue(followUpPr.store().labelNames().contains(\"ready\"));\n\n            // Push something else unrelated to the target\n            var currentMaster = localRepo.fetch(author.authenticatedUrl(), \"master\").orElseThrow();\n            localRepo.checkout(currentMaster, true);\n            var unrelatedFile2 = localRepo.root().resolve(\"unrelated2.txt\");\n            Files.writeString(unrelatedFile2, \"Some other things happens in master\");\n            localRepo.add(unrelatedFile2);\n            newMasterHash = localRepo.commit(\"Second unrelated change\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(newMasterHash, author.authenticatedUrl(), \"master\");\n\n            // Refresh the status\n            followUpPr.setBody(followUpPr.body() + \" recheck\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Try to integrate it again\n            followUpPr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertLastCommentContains(followUpPr, \"Pushed as commit\");\n\n            // Check that everything is present\n            var finalMaster = localRepo.fetch(author.authenticatedUrl(), \"master\").orElseThrow();\n            localRepo.checkout(finalMaster, true);\n            assertEquals(\"Other things happens in master\", Files.readString(localRepo.root().resolve(\"unrelated.txt\")));\n            assertEquals(\"Some other things happens in master\", Files.readString(localRepo.root().resolve(\"unrelated2.txt\")));\n            assertTrue(CheckableRepository.hasBeenEdited(localRepo));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/PullRequestAsserts.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.PullRequest;\n\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\npublic class PullRequestAsserts {\n    public static void assertLastCommentContains(PullRequest pr, String contains) {\n        var comments = pr.comments();\n        assertTrue(!comments.isEmpty());\n        var lastComment = comments.getLast();\n        assertTrue(lastComment.body().contains(contains), lastComment.body());\n    }\n\n    public static void assertFirstCommentContains(PullRequest pr, String contains) {\n        var comments = pr.comments();\n        assertTrue(!comments.isEmpty());\n        var firstComment = comments.get(0);\n        assertTrue(firstComment.body().contains(contains), firstComment.body());\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/PullRequestBotFactoryTest.java",
    "content": "/*\n * Copyright (c) 2022, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.json.JWCC;\nimport org.openjdk.skara.test.*;\n\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass PullRequestBotFactoryTest {\n    @Test\n    public void testCreate() {\n        try (var tempFolder = new TemporaryDirectory()) {\n            String jsonString = \"\"\"\n                    {\n                      \"external\": {\n                        \"pr\": {\n                          \"test\": \"used to run tests\"\n                        },\n                        \"commit\": {\n                          \"command1\": \"test1\",\n                          \"command2\": \"test2\"\n                        }\n                      },\n                      \"exclude-commit-comments-from\": [\n                          1,\n                          2\n                      ],\n                      \"blockers\": {\n                        \"test\": \"Signature needs verify\"\n                      },\n                      \"ready\": {\n                        \"labels\": [],\n                        \"comments\": []\n                      },\n                      \"labels\": {\n                        \"label1\": {\n                          \"repository\": \"repo1:master\",\n                          \"filename\": \"file.json\"\n                        }\n                      },\n                      \"requiredCheckedLines\": [ \"foo\" ],\n                      \"trailers\": [\n                        {\n                          \"key\": \"global-trailer\",\n                          \"alias\": \"global\",\n                          \"description\": \"Example global trailer\",\n                          \"type\": \"single\",\n                          \"values\": \"foo.*\"\n                        }\n                      ],\n                      \"repositories\": {\n                        \"repo2\": {\n                          \"census\": \"census:master\",\n                          \"censuslink\": \"https://test.test.com\",\n                          \"issues\": \"TEST\",\n                          \"csr\": true,\n                          \"backport\": false,\n                          \"merge\": false,\n                          \"two-reviewers\": [\n                            \"rfr\"\n                          ],\n                          \"24h\": [\n                            \"24h_test\"\n                          ],\n                          \"integrators\": [\n                            \"integrator1\",\n                            \"integrator2\"\n                          ],\n                          \"reviewCleanBackport\": true,\n                          \"mergeSources\": [\n                            \"openjdk/playground\",\n                            \"openjdk/skara\",\n                          ]\n                        },\n                        \"repo5\": {\n                          \"census\": \"census:master\",\n                          \"censuslink\": \"https://test.test.com\",\n                          \"issues\": \"TEST2\",\n                          \"csr\": true,\n                          \"backport\": true,\n                          \"merge\": true,\n                          \"two-reviewers\": [\n                            \"rfr\"\n                          ],\n                          \"24h\": [\n                            \"24h_test\"\n                          ],\n                          \"integrators\": [\n                            \"integrator1\",\n                            \"integrator2\"\n                          ],\n                          \"reviewCleanBackport\": true,\n                          \"processCommit\": false,\n                          \"requiredCheckedLines\": [ \"bar\" ]\n                        },\n                        \"repo6\": {\n                          \"census\": \"census:master\",\n                          \"censuslink\": \"https://test.test.com\",\n                          \"issues\": \"TEST2\",\n                          \"csr\": false,\n                          \"two-reviewers\": [\n                            \"rfr\"\n                          ],\n                          \"24h\": [\n                            \"24h_test\"\n                          ],\n                          \"integrators\": [\n                            \"integrator1\",\n                            \"integrator2\"\n                          ],\n                          \"reviewCleanBackport\": true,\n                          \"reviewMerge\": \"always\",\n                          \"processPR\": false,\n                          \"jcheckMerge\": true,\n                          \"versionMismatchWarning\": false,\n                        },\n                        \"repo7\": {\n                          \"census\": \"census:master\",\n                          \"censuslink\": \"https://test.test.com\",\n                          \"issues\": \"TEST3\",\n                          \"two-reviewers\": [\n                            \"rfr\"\n                          ],\n                          \"24h\": [\n                            \"24h_test\"\n                          ],\n                          \"integrators\": [\n                            \"integrator1\",\n                            \"integrator2\"\n                          ],\n                          \"reviewCleanBackport\": true,\n                          \"reviewMerge\": \"always\",\n                          \"processPR\": false,\n                          \"jcheckMerge\": false\n                          \"approval\": {\n                            \"request\": \"-critical-request\",\n                            \"approved\": \"-critical-approved\",\n                            \"rejected\": \"-critical-rejected\",\n                            \"documentLink\": \"https://example.com\",\n                            \"branches\": {\n                              \"jdk20.0.1\": { \"prefix\": \"CPU23_04\" },\n                              \"jdk20.0.2\": { \"prefix\": \"CPU23_05\" },\n                              }\n                          },\n                          \"versionMismatchWarning\": true,\n                          \"cleanCommandEnabled\": false,\n                          \"trailers\": [\n                            {\n                              \"key\": \"trailer-1\",\n                              \"description\": \"Example repo specific trailer\",\n                              \"type\": \"list\",\n                              \"values\": [\n                                \"foo\",\n                                \"bar\"\n                              ]\n                            }\n                          ],\n                        }\n                      },\n                      \"forks\": {\n                        \"repo3\": \"fork3\",\n                        \"repo4\": \"fork4\",\n                      },\n                      \"mlbridge\": \"mlbridge[bot]\"\n                    }\n                    \"\"\";\n            var jsonConfig = JWCC.parse(jsonString).asObject();\n\n            var testBotFactory = TestBotFactory.newBuilder()\n                    .addHostedRepository(\"repo1\", new TestHostedRepository(\"repo1\"))\n                    .addHostedRepository(\"repo2\", new TestHostedRepository(TestHost.createNew(List.of()), \"repo2\"))\n                    .addHostedRepository(\"repo3\", new TestHostedRepository(\"repo3\"))\n                    .addHostedRepository(\"repo4\", new TestHostedRepository(\"repo4\"))\n                    .addHostedRepository(\"repo5\", new TestHostedRepository(TestHost.createNew(List.of()), \"repo5\"))\n                    .addHostedRepository(\"repo6\", new TestHostedRepository(TestHost.createNew(List.of()), \"repo6\"))\n                    .addHostedRepository(\"repo7\", new TestHostedRepository(TestHost.createNew(List.of()), \"repo7\"))\n                    .addHostedRepository(\"fork3\", new TestHostedRepository(\"fork3\"))\n                    .addHostedRepository(\"fork4\", new TestHostedRepository(\"fork4\"))\n                    .addHostedRepository(\"census\", new TestHostedRepository(\"census\"))\n                    .addIssueProject(\"TEST\", new TestIssueProject(TestHost.createNew(List.of()), \"TEST\"))\n                    .addIssueProject(\"TEST2\", new TestIssueProject(TestHost.createNew(List.of()), \"TEST2\"))\n                    .addIssueProject(\"TEST3\", new TestIssueProject(TestHost.createNew(List.of()), \"TEST3\"))\n                    .storagePath(tempFolder.path().resolve(\"storage\"))\n                    .build();\n\n            var bots = testBotFactory.createBots(PullRequestBotFactory.NAME, jsonConfig);\n            // A pullRequestBot for every configured repository and A CSRIssueBot for every configured issue project with any repo configured with 'csr: true'\n            // A IssueBot for every configured issue project\n            // No CSRIssueBot created for issueTracker TEST3 because it is not associated with any CSR enabled repo\n            assertEquals(9, bots.size());\n\n            var pullRequestBot2 = (PullRequestBot) bots.stream()\n                    .filter(bot -> bot.toString().equals(\"PullRequestBot@repo2\"))\n                    .findFirst().orElseThrow();\n            assertEquals(\"PullRequestBot@repo2\", pullRequestBot2.toString());\n            assertFalse(pullRequestBot2.enableMerge());\n            assertTrue(pullRequestBot2.mergeSources().contains(\"openjdk/skara\"));\n            assertTrue(pullRequestBot2.mergeSources().contains(\"openjdk/playground\"));\n            assertFalse(pullRequestBot2.jcheckMerge());\n            assertFalse(pullRequestBot2.enableBackport());\n            assertEquals(List.of(\"foo\"), pullRequestBot2.requiredCheckedLines());\n            assertEquals(1, pullRequestBot2.trailerConfigs().size());\n            TrailerCommand.TrailerConfig trailerConfig = pullRequestBot2.trailerConfigs().getFirst();\n            assertEquals(\"global-trailer\", trailerConfig.key());\n            assertEquals(\"global\", trailerConfig.alias());\n            assertEquals(\"Example global trailer\", trailerConfig.description());\n            assertEquals(TrailerCommand.TrailerType.SINGLE, trailerConfig.type());\n            assertEquals(\"foo.*\", trailerConfig.values().getFirst().pattern());\n\n            var pullRequestBot5 = (PullRequestBot) bots.stream()\n                    .filter(bot -> bot.toString().equals(\"PullRequestBot@repo5\"))\n                    .findFirst().orElseThrow();\n            assertEquals(\"PullRequestBot@repo5\", pullRequestBot5.toString());\n            assertTrue(pullRequestBot5.enableMerge());\n            assertFalse(pullRequestBot5.jcheckMerge());\n            assertTrue(pullRequestBot5.enableBackport());\n            assertFalse(pullRequestBot5.versionMismatchWarning());\n            assertTrue(pullRequestBot5.cleanCommandEnabled());\n            assertEquals(List.of(\"bar\"), pullRequestBot5.requiredCheckedLines());\n            assertEquals(1, pullRequestBot5.trailerConfigs().size());\n\n            var pullRequestBot6 = (PullRequestBot) bots.stream()\n                    .filter(bot -> bot.toString().equals(\"PullRequestBot@repo6\"))\n                    .findFirst().orElseThrow();\n            assertEquals(\"PullRequestBot@repo6\", pullRequestBot6.toString());\n            assertEquals(\"used to run tests\", pullRequestBot6.externalPullRequestCommands().get(\"test\"));\n            assertEquals(\"TEST2\", pullRequestBot6.issueProject().name());\n            assertEquals(\"census\", pullRequestBot6.censusRepo().name());\n            assertEquals(\"master\", pullRequestBot6.censusRef());\n            assertEquals(\"{test=used to run tests}\", pullRequestBot6.externalPullRequestCommands().toString());\n            assertEquals(\"{test=Signature needs verify}\", pullRequestBot6.blockingCheckLabels().toString());\n            assertEquals(\"[rfr]\", pullRequestBot6.twoReviewersLabels().toString());\n            assertEquals(\"[24h_test]\", pullRequestBot6.twentyFourHoursLabels().toString());\n            assertTrue(pullRequestBot6.useStaleReviews());\n            assertEquals(\".*\", pullRequestBot6.allowedTargetBranches().toString());\n            var integrators = pullRequestBot6.integrators();\n            assertEquals(2, integrators.size());\n            assertTrue(integrators.contains(\"integrator1\"));\n            assertTrue(integrators.contains(\"integrator2\"));\n            assertTrue(pullRequestBot6.reviewCleanBackport());\n            assertEquals(MergePullRequestReviewConfiguration.ALWAYS, pullRequestBot6.reviewMerge());\n            assertEquals(\"mlbridge[bot]\", pullRequestBot6.mlbridgeBotName());\n            assertTrue(pullRequestBot6.enableMerge());\n            assertTrue(pullRequestBot6.jcheckMerge());\n            assertTrue(pullRequestBot6.enableBackport());\n            assertFalse(pullRequestBot6.versionMismatchWarning());\n            assertEquals(List.of(\"foo\"), pullRequestBot6.requiredCheckedLines());\n            assertEquals(1, pullRequestBot6.trailerConfigs().size());\n\n            var pullRequestBot7 = (PullRequestBot) bots.stream()\n                    .filter(bot -> bot.toString().equals(\"PullRequestBot@repo7\"))\n                    .findFirst().orElseThrow();\n            assertEquals(\"PullRequestBot@repo7\", pullRequestBot7.toString());\n            assertFalse(pullRequestBot7.jcheckMerge());\n            assertEquals(\"https://example.com\", pullRequestBot7.approval().documentLink());\n            assertTrue(pullRequestBot7.versionMismatchWarning());\n            assertFalse(pullRequestBot7.cleanCommandEnabled());\n            assertEquals(List.of(\"foo\"), pullRequestBot7.requiredCheckedLines());\n            assertEquals(1, pullRequestBot7.trailerConfigs().size());\n            TrailerCommand.TrailerConfig trailerConfig1 = pullRequestBot7.trailerConfigs().getFirst();\n            assertEquals(\"trailer-1\", trailerConfig1.key());\n            assertNull(trailerConfig1.alias());\n            assertEquals(\"Example repo specific trailer\", trailerConfig1.description());\n            assertEquals(TrailerCommand.TrailerType.LIST, trailerConfig1.type());\n            assertEquals(\"foo\", trailerConfig1.values().getFirst().pattern());\n            assertEquals(\"bar\", trailerConfig1.values().get(1).pattern());\n\n            var csrIssueBot1 = (CSRIssueBot) bots.stream()\n                    .filter(bot -> bot.toString().equals(\"CSRIssueBot@TEST\"))\n                    .findFirst().orElseThrow();\n            // repo5 and repo6 are both configured with issueProject TEST2, but only repo5 is enabled csr\n            assertEquals(1, csrIssueBot1.repositories().size());\n            assertNotNull(csrIssueBot1.getPRBot(\"repo5\"));\n            assertEquals(\"CSRIssueBot@TEST\", csrIssueBot1.toString());\n\n            var csrIssueBot2 = (CSRIssueBot) bots.stream()\n                    .filter(bot -> bot.toString().equals(\"CSRIssueBot@TEST2\"))\n                    .findFirst().orElseThrow();\n            assertEquals(1, csrIssueBot2.repositories().size());\n            assertNotNull(csrIssueBot2.getPRBot(\"repo2\"));\n            assertEquals(\"CSRIssueBot@TEST2\", csrIssueBot2.toString());\n\n            var issueBot1 = (IssueBot) bots.stream()\n                    .filter(bot -> bot.toString().equals(\"IssueBot@TEST\"))\n                    .findFirst().orElseThrow();\n            assertEquals(\"IssueBot@TEST\", issueBot1.toString());\n            // repo2 is configured with issueProject TEST\n            assertEquals(1, issueBot1.repositories().size());\n\n            var issueBot2 = (IssueBot) bots.stream()\n                    .filter(bot -> bot.toString().equals(\"IssueBot@TEST2\"))\n                    .findFirst().orElseThrow();\n            assertEquals(\"IssueBot@TEST2\", issueBot2.toString());\n            // repo5 and repo6 are both configured with issueProject TEST2\n            assertEquals(2, issueBot2.repositories().size());\n\n            var issueBot3 = (IssueBot) bots.stream()\n                    .filter(bot -> bot.toString().equals(\"IssueBot@TEST3\"))\n                    .findFirst().orElseThrow();\n            assertEquals(\"IssueBot@TEST3\", issueBot3.toString());\n            // repo7 is configured with issueProject TEST3\n            assertEquals(1, issueBot3.repositories().size());\n\n            // prBot for repo2, issueBot for TEST and csrIssueBot for TEST should share the same map\n            assertSame(pullRequestBot2.issuePRMap(), issueBot1.issuePRMap());\n            assertSame(pullRequestBot2.issuePRMap(), csrIssueBot1.issuePRMap());\n            // prBot for repo5, repo6, issueBot for TEST2 and csrIssueBot for TEST2 should share the same map\n            assertSame(pullRequestBot6.issuePRMap(), pullRequestBot5.issuePRMap());\n            assertSame(pullRequestBot6.issuePRMap(), issueBot2.issuePRMap());\n            assertSame(pullRequestBot6.issuePRMap(), csrIssueBot2.issuePRMap());\n            // prBot for repo7 and issueBot for TEST3 should share the same map\n            assertSame(pullRequestBot7.issuePRMap(), issueBot3.issuePRMap());\n\n            assertNotSame(pullRequestBot6.issuePRMap(), pullRequestBot2.issuePRMap());\n            assertNotSame(pullRequestBot6.issuePRMap(), pullRequestBot7.issuePRMap());\n            assertNotSame(pullRequestBot7.issuePRMap(), pullRequestBot2.issuePRMap());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/PullRequestCommandTests.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.forge.Review;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.util.Map;\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\nclass PullRequestCommandTests {\n    @Test\n    void invalidCommand(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue a commit command\n            pr.addComment(\"/tag\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            PullRequestAsserts.assertLastCommentContains(pr, \"The command `tag` can not be used in pull requests.\");\n\n            // Issue an invalid command\n            pr.addComment(\"/howdy\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"Unknown command\"))\n                          .filter(comment -> comment.body().contains(\"help\"))\n                          .count();\n            assertEquals(1, error);\n        }\n    }\n\n    @Test\n    void helpCommand(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue an invalid command\n            pr.addComment(\"/help\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with some help\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"Available commands\"))\n                          .filter(comment -> comment.body().contains(\"help\"))\n                          .filter(comment -> comment.body().contains(\"integrate\"))\n                          .filter(comment -> comment.body().contains(\"sponsor\"))\n                          .count();\n            assertEquals(1, error);\n        }\n    }\n\n    @Test\n    void multipleCommands(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue multiple commands in a comment\n            pr.addComment(\"/contributor add A <a@b.c>\\n/summary line 1\\nline 2\\n/contributor add B <b@c.d>\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Each command should get a separate reply\n            assertEquals(5, pr.comments().size());\n            assertTrue(pr.comments().get(2).body().contains(\"Contributor `A <a@b.c>` successfully added\"), pr.comments().get(2).body());\n            assertTrue(pr.comments().get(3).body().contains(\"Setting summary to:\\n\" +\n                                                                    \"\\n\" +\n                                                                    \"```\\n\" +\n                                                                    \"line 1\\n\" +\n                                                                    \"line 2\"), pr.comments().get(3).body());\n            assertTrue(pr.comments().get(4).body().contains(\"Contributor `B <b@c.d>` successfully added\"), pr.comments().get(4).body());\n\n            // They should only be executed once\n            TestBotRunner.runPeriodicItems(mergeBot);\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertEquals(5, pr.comments().size());\n        }\n    }\n\n    @Test\n    void selfCommand(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue an command using the bot account\n            var botPr = integrator.pullRequest(pr.id());\n            botPr.addComment(\"/help\");\n\n            // The bot should not reply\n            assertEquals(1, pr.comments().size());\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertEquals(2, pr.comments().size());\n\n            // But if we add an overriding marker, it should\n            botPr.addComment(\"/help\\n<!-- Valid self-command -->\");\n\n            assertEquals(3, pr.comments().size());\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertEquals(4, pr.comments().size());\n\n            var help = pr.comments().stream()\n                         .filter(comment -> comment.body().contains(\"Available commands\"))\n                         .filter(comment -> comment.body().contains(\"help\"))\n                         .count();\n            assertEquals(1, help);\n        }\n    }\n\n    @Test\n    void inBody(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder()\n                                         .repo(integrator)\n                                         .censusRepo(censusBuilder.build())\n                                         .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue an invalid body command\n            pr.setBody(\"This is a body\\n/contributor add A <a@b.c>\\n/contributor add B <b@c.d>\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The second command reply should be the last comment\n            assertLastCommentContains(pr, \"Contributor `B <b@c.d>` successfully added.\");\n\n            // The first command should also be reflected in the body\n            assertTrue(pr.store().body().contains(\"A `<a@b.c>`\"));\n        }\n    }\n\n    @Test\n    void disallowedInBody(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue an invalid body command\n            pr.setBody(\"/help\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with some help\n            assertLastCommentContains(pr, \"The command `help` cannot be used in the pull request body\");\n        }\n    }\n\n    @Test\n    void externalCommandFollowedByNonExternalCommand(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder()\n                                         .repo(integrator)\n                                         .censusRepo(censusBuilder.build())\n                                         .externalPullRequestCommands(Map.of(\"external\", \"Help for external command\"))\n                                         .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue an external command\n            var externalCommandComment = pr.addComment(\"/external\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should not reply since the external command will be handled by another bot\n            assertLastCommentContains(pr, \"This change is not yet ready to be integrated.\");\n\n            // Issue the help command\n            pr.addComment(\"/help\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with help\n            assertLastCommentContains(pr, \"@user1 Available commands:\");\n            assertLastCommentContains(pr, \" * help - shows this text\");\n            assertLastCommentContains(pr, \" * external - Help for external command\");\n        }\n    }\n\n    @Test\n    void summaryCommandInBodyWithBotComment(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\", List.of(\n                \"/summary\",\n                \"This is a multi-line summary\",\n                \"\",\n                \"With multiple paragraphs\",\n                \"\",\n                \"Even a final one at the end\"\n            ));\n\n            // Run the bot\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with some help\n            assertLastCommentContains(pr, \"Setting summary to:\\n\\n\" +\n                                          \"```\\n\" +\n                                          \"This is a multi-line summary\\n\" +\n                                          \"\\n\" +\n                                          \"With multiple paragraphs\\n\" +\n                                          \"\\n\" +\n                                          \"Even a final one at the end\\n\" +\n                                          \"```\\n\");\n        }\n    }\n\n    @Test\n    void interpretCommandFromReviews(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addAuthor(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Test command in review\n            pr.addReview(Review.Verdict.APPROVED, \"/reviewers 3\");\n            // Run the bot\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"The total number of required reviews for this PR (including the jcheck configuration and the last /reviewers command) is now set to 3\");\n\n            // This should work\n            pr.addReview(Review.Verdict.APPROVED, \"first line \\n  /reviewers 4\");\n            // Run the bot\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"The total number of required reviews for this PR (including the jcheck configuration and the last /reviewers command) is now set to 4\");\n\n            // This should not work\n            pr.addReview(Review.Verdict.APPROVED, \"first line \\n  second line /reviewers 5\");\n            // Run the bot\n            TestBotRunner.runPeriodicItems(prBot);\n            // Reviewer number is still 4\n            assertLastCommentContains(pr, \"The total number of required reviews for this PR (including the jcheck configuration and the last /reviewers command) is now set to 4\");\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/RequiredCheckedLinesTests.java",
    "content": "/*\n * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\nimport org.openjdk.skara.forge.Review;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.common.PullRequestConstants.PROGRESS_MARKER;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\nclass RequiredCheckedLinesTests {\n    @Test\n    void dashLowerCaseChecked(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var committer = credentials.getHostedRepository();\n\n            var census = credentials.getCensusBuilder()\n                .addCommitter(committer.forge().currentUser().id())\n                .build();\n            var seedPath = tmp.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(committer)\n                    .censusRepo(census)\n                    .seedStorage(seedPath)\n                    .requiredCheckedLines(List.of(\"foo\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tmp.path(), committer.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, committer.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR using lowercase x for checkbox\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, committer.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(committer, \"master\", \"edit\", \"A PR\",\n                List.of(\"- [x] foo\")\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // PR should be ready for review\n            var updatedPR = committer.pullRequest(pr.id());\n            assertTrue(updatedPR.body().startsWith(\n                \"- [x] foo\" +\n                \"\\n\\n\" +\n                PROGRESS_MARKER\n            ), updatedPR.body());\n            assertEquals(List.of(\"rfr\"), updatedPR.labelNames());\n        }\n    }\n\n    @Test\n    void dashUpperCaseChecked(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var committer = credentials.getHostedRepository();\n\n            var census = credentials.getCensusBuilder()\n                .addCommitter(committer.forge().currentUser().id())\n                .build();\n            var seedPath = tmp.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(committer)\n                    .censusRepo(census)\n                    .seedStorage(seedPath)\n                    .requiredCheckedLines(List.of(\"foo\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tmp.path(), committer.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, committer.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR using upper case X in checkbox\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, committer.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(committer, \"master\", \"edit\", \"A PR\",\n                List.of(\"- [X] foo\")\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // PR should be ready for review\n            var updatedPR = committer.pullRequest(pr.id());\n            assertTrue(updatedPR.body().startsWith(\n                \"- [X] foo\" +\n                \"\\n\\n\" +\n                PROGRESS_MARKER\n            ), updatedPR.body());\n            assertEquals(List.of(\"rfr\"), updatedPR.labelNames());\n        }\n    }\n\n    @Test\n    void checkedWithTrailingWhitespace(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var committer = credentials.getHostedRepository();\n\n            var census = credentials.getCensusBuilder()\n                .addCommitter(committer.forge().currentUser().id())\n                .build();\n            var seedPath = tmp.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(committer)\n                    .censusRepo(census)\n                    .seedStorage(seedPath)\n                    .requiredCheckedLines(List.of(\"foo\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tmp.path(), committer.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, committer.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with trailing whitespace after checkbox line\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, committer.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(committer, \"master\", \"edit\", \"A PR\",\n                List.of(\"First line\",\n                        \"- [x] foo   \\t\\t   \",\n                        \"Last line\")\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // PR should be ready for review\n            var updatedPR = committer.pullRequest(pr.id());\n            assertEquals(List.of(\"rfr\"), updatedPR.labelNames());\n            assertTrue(updatedPR.body().startsWith(\n                \"First line\\n\" +\n                \"- [x] foo   \\t\\t   \\n\" +\n                \"Last line\" +\n                \"\\n\\n\" +\n                PROGRESS_MARKER\n            ), updatedPR.body());\n        }\n    }\n\n    @Test\n    void multipleCheckedRequiredLines(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var committer = credentials.getHostedRepository();\n\n            var census = credentials.getCensusBuilder()\n                .addCommitter(committer.forge().currentUser().id())\n                .build();\n            var seedPath = tmp.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(committer)\n                    .censusRepo(census)\n                    .seedStorage(seedPath)\n                    .requiredCheckedLines(List.of(\"foo\", \"bar\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tmp.path(), committer.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, committer.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR, do not add any required line\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, committer.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(committer, \"master\", \"edit\", \"A PR\",\n                List.of(\"This is a pull request\")\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Should not be ready for review (nor have any other labels)\n            var updatedPR = committer.pullRequest(pr.id());\n            assertEquals(List.of(), updatedPR.labelNames());\n\n            // Should have errors for both missing required lines\n            var lines = updatedPR.body().lines().toList();\n            var foundUncheckedErrorLineForFoo = false;\n            var foundUncheckedErrorLineForBar = false;\n            for (int i = 0; i < lines.size(); i++) {\n                if (lines.get(i).startsWith(\"### Error\")) {\n                    for (int j = i+1; j < lines.size(); j++) {\n                        if (!lines.get(j).startsWith(\"&nbsp;\")) {\n                            break; // no longer an error list item\n                        }\n\n                        if (lines.get(j).contains(\"Pull request body is missing required line: `- [x] foo`\")) {\n                            assertFalse(foundUncheckedErrorLineForFoo, \"Should only be one error\");\n                            foundUncheckedErrorLineForFoo = true;\n                        } else if (lines.get(j).contains(\"Pull request body is missing required line: `- [x] bar`\")) {\n                            assertFalse(foundUncheckedErrorLineForBar, \"Should only be one error\");\n                            foundUncheckedErrorLineForBar = true;\n                        }\n                    }\n                }\n            }\n            assertTrue(foundUncheckedErrorLineForFoo, updatedPR.body());\n            assertTrue(foundUncheckedErrorLineForBar, updatedPR.body());\n\n            // Add one of the required lines\n            updatedPR.setBody(\n                \"This is a pull request\\n\" +\n                \"- [x] foo\"\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Should still not be ready for review (nor have any other labels)\n            updatedPR = committer.pullRequest(pr.id());\n            assertEquals(List.of(), updatedPR.labelNames());\n\n            // Should have error for missing required line \"bar\"\n            lines = updatedPR.body().lines().toList();\n            foundUncheckedErrorLineForBar = false;\n            for (int i = 0; i < lines.size(); i++) {\n                if (lines.get(i).startsWith(\"### Error\")) {\n                    for (int j = i+1; j < lines.size(); j++) {\n                        if (!lines.get(j).startsWith(\"&nbsp;\")) {\n                            break; // no longer an error list item\n                        }\n\n                        if (lines.get(j).contains(\"Pull request body is missing required line: `- [x] bar`\")) {\n                            assertFalse(foundUncheckedErrorLineForBar, \"Should only be one error\");\n                            foundUncheckedErrorLineForBar = true;\n                        }\n                    }\n                }\n            }\n            assertTrue(foundUncheckedErrorLineForBar, updatedPR.body());\n\n            // Add both of the required lines\n            updatedPR.setBody(\n                \"This is a pull request\\n\" +\n                \"- [x] foo\\n\" +\n                \"- [x] bar\"\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Should be ready for review\n            updatedPR = committer.pullRequest(pr.id());\n            assertEquals(List.of(\"rfr\"), updatedPR.labelNames());\n\n            // There should not be any errors\n            lines = updatedPR.body().lines().toList();\n            for (var line : lines) {\n                assertFalse(line.startsWith(\"### Error\"), line);\n            }\n        }\n    }\n\n    @Test\n    void uncheckedLineBlocksReadyForReview(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var committer = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var census = credentials.getCensusBuilder()\n                .addCommitter(committer.forge().currentUser().id())\n                .addReviewer(reviewer.forge().currentUser().id())\n                .build();\n            var seedPath = tmp.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(committer)\n                    .censusRepo(census)\n                    .seedStorage(seedPath)\n                    .requiredCheckedLines(List.of(\"foo\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tmp.path(), committer.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, committer.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with unchecked line\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, committer.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(committer, \"master\", \"edit\", \"A PR\",\n                List.of(\"This is a pull request\",\n                        \"- [ ] foo\")\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Pull request should not be ready for review\n            var updatedPR = committer.pullRequest(pr.id());\n            assertTrue(updatedPR.body().startsWith(\n                \"This is a pull request\\n\" +\n                \"- [ ] foo\" +\n                \"\\n\\n\" +\n                PROGRESS_MARKER\n            ), updatedPR.body());\n\n            // Should not be ready for review (nor have any other labels)\n            assertEquals(List.of(), updatedPR.labelNames());\n\n            // Should have an error\n            var lines = updatedPR.body().lines().toList();\n            var foundUncheckedErrorLine = false;\n            for (int i = 0; i < lines.size(); i++) {\n                if (lines.get(i).startsWith(\"### Error\")) {\n                    for (int j = i+1; j < lines.size(); j++) {\n                        if (!lines.get(j).startsWith(\"&nbsp;\")) {\n                            break; // no longer an error list item\n                        }\n\n                        if (lines.get(j).contains(\"Pull request body is missing required line: `- [x] foo`\")) {\n                            assertFalse(foundUncheckedErrorLine, \"Should only be one error\");\n                            foundUncheckedErrorLine = true;\n                        }\n                    }\n                }\n            }\n            assertTrue(foundUncheckedErrorLine, updatedPR.body());\n\n            // Add checked line\n            updatedPR.setBody(\n                \"This is a pull request\\n\" +\n                \"- [x] foo\"\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Should be ready for review\n            updatedPR = committer.pullRequest(pr.id());\n            assertEquals(List.of(\"rfr\"), updatedPR.labelNames());\n\n            // There should not be any errors\n            lines = updatedPR.body().lines().toList();\n            for (var line : lines) {\n                assertFalse(line.startsWith(\"### Error\"), line);\n            }\n        }\n    }\n\n    @Test\n    void cleanBackportRequiresCheckedLines(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var issues = credentials.getIssueProject();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                    .repo(integrator)\n                    .censusRepo(censusBuilder.build())\n                    .issueProject(issues)\n                    .issuePRMap(new HashMap<>())\n                    .requiredCheckedLines(List.of(\"foo\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tmp.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            var releaseBranch = localRepo.branch(masterHash, \"release\");\n            localRepo.checkout(releaseBranch);\n            var newFile = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile, \"hello\");\n            localRepo.add(newFile);\n            var issue1 = credentials.createIssue(issues, \"An issue\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var releaseHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(releaseHash, author.authenticatedUrl(), \"refs/heads/release\", true);\n\n            // \"backport\" the new file to the master branch\n            localRepo.checkout(localRepo.defaultBranch());\n            var editBranch = localRepo.branch(masterHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var newFile2 = localRepo.root().resolve(\"a_new_file.txt\");\n            Files.writeString(newFile2, \"hello\");\n            localRepo.add(newFile2);\n            var editHash = localRepo.commit(\"Backport\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Backport \" + releaseHash.hex(),\n                    List.of(\"This is a clean backport pull request\"));\n\n            // Check status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // The bot should reply with a backport message\n            var updatedPR = author.pullRequest(pr.id());\n            var backportComment = updatedPR.comments().get(1).body();\n            assertTrue(backportComment.contains(\"This backport pull request has now been updated with issue\"));\n            assertTrue(backportComment.contains(\"<!-- backport \" + releaseHash.hex() + \" -->\"));\n            assertEquals(issue1Number + \": An issue\", updatedPR.title());\n\n            // The pull request should not be ready for review\n            var labels = updatedPR.labelNames();\n            Collections.sort(labels);\n            assertEquals(List.of(\"backport\", \"clean\"), labels);\n\n            // Should have an error\n            var lines = updatedPR.body().lines().toList();\n            var foundUncheckedErrorLine = false;\n            for (int i = 0; i < lines.size(); i++) {\n                if (lines.get(i).startsWith(\"### Error\")) {\n                    for (int j = i+1; j < lines.size(); j++) {\n                        if (!lines.get(j).startsWith(\"&nbsp;\")) {\n                            break; // no longer an error list item\n                        }\n\n                        if (lines.get(j).contains(\"Pull request body is missing required line: `- [x] foo`\")) {\n                            assertFalse(foundUncheckedErrorLine, \"Should only be one error\");\n                            foundUncheckedErrorLine = true;\n                        }\n                    }\n                }\n            }\n            assertTrue(foundUncheckedErrorLine, updatedPR.body());\n\n            // Add checked line\n            updatedPR.setBody(\n                \"This is a clean backport pull request\\n\" +\n                \"- [x] foo\"\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Should be ready for integration\n            updatedPR = author.pullRequest(pr.id());\n            labels = updatedPR.labelNames();\n            Collections.sort(labels);\n            assertEquals(List.of(\"backport\", \"clean\", \"ready\", \"rfr\"), labels);\n\n            // There should not be any errors\n            lines = updatedPR.body().lines().toList();\n            for (var line : lines) {\n                assertFalse(line.startsWith(\"### Error\"), line);\n            }\n        }\n    }\n\n    @Test\n    void cleanMergePRRequiresCheckedLines(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(integrator)\n                                    .censusRepo(censusBuilder.build())\n                                    .requiredCheckedLines(List.of(\"foo\"))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tmp.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make more changes in another branch\n            var otherHash1 = CheckableRepository.appendAndCommit(localRepo, \"First change in other_/-1.2\",\n                                                                \"First other_/-1.2\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash1, author.authenticatedUrl(), \"other_/-1.2\", true);\n            var otherHash2 = CheckableRepository.appendAndCommit(localRepo, \"Second change in other_/-1.2\",\n                                                                \"Second other_/-1.2\\n\\nReviewed-by: integrationreviewer2\");\n            localRepo.push(otherHash2, author.authenticatedUrl(), \"other_/-1.2\");\n\n            // Go back to the original master\n            localRepo.checkout(masterHash, true);\n\n            // Make a change with a corresponding PR\n            var unrelated = Files.writeString(localRepo.root().resolve(\"unrelated.txt\"), \"Unrelated\");\n            localRepo.add(unrelated);\n            var updatedMaster = localRepo.commit(\"Unrelated\", \"some\", \"some@one\");\n            localRepo.merge(otherHash2);\n            localRepo.push(updatedMaster, author.authenticatedUrl(), \"master\");\n\n            var mergeHash = localRepo.commit(\"Merge commit\", \"some\", \"some@one\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"Merge \" + author.name() + \":other_/-1.2\",\n                List.of(\"This is a merge-style pull request\")\n            );\n\n            // Let the bot check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Merge PR should be clean but not ready for review\n            var updatedPR = author.pullRequest(pr.id());\n            var labels = updatedPR.labelNames();\n            Collections.sort(labels);\n            assertEquals(List.of(\"clean\"), labels);\n\n            // Should have an error\n            var lines = updatedPR.body().lines().toList();\n            var foundUncheckedErrorLine = false;\n            for (int i = 0; i < lines.size(); i++) {\n                if (lines.get(i).startsWith(\"### Error\")) {\n                    for (int j = i+1; j < lines.size(); j++) {\n                        if (!lines.get(j).startsWith(\"&nbsp;\")) {\n                            break; // no longer an error list item\n                        }\n\n                        if (lines.get(j).contains(\"Pull request body is missing required line: `- [x] foo`\")) {\n                            assertFalse(foundUncheckedErrorLine, \"Should only be one error\");\n                            foundUncheckedErrorLine = true;\n                        }\n                    }\n                }\n            }\n            assertTrue(foundUncheckedErrorLine, updatedPR.body());\n\n            // Add checked line\n            updatedPR.setBody(\n                \"This is a merge-style pull request\\n\" +\n                \"- [x] foo\"\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Should be ready for integration\n            updatedPR = author.pullRequest(pr.id());\n            labels = updatedPR.labelNames();\n            Collections.sort(labels);\n            assertEquals(List.of(\"clean\", \"ready\", \"rfr\"), labels);\n\n            // There should not be any errors\n            lines = updatedPR.body().lines().toList();\n            for (var line : lines) {\n                assertFalse(line.startsWith(\"### Error\"), line);\n            }\n        }\n    }\n\n    @Test\n    void checkedLineInBlockComment(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var committer = credentials.getHostedRepository();\n\n            var census = credentials.getCensusBuilder()\n                .addCommitter(committer.forge().currentUser().id())\n                .build();\n            var seedPath = tmp.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(committer)\n                    .censusRepo(census)\n                    .seedStorage(seedPath)\n                    .requiredCheckedLines(List.of(\"foo\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tmp.path(), committer.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, committer.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with checked line in HTML block comment\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, committer.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(committer, \"master\", \"edit\", \"A PR\",\n                List.of(\"<!--\",\n                        \"- [x] foo\",\n                        \"-->\"\n                )\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // PR should not be ready for review\n            var updatedPR = committer.pullRequest(pr.id());\n            assertEquals(List.of(), updatedPR.labelNames());\n        }\n    }\n\n    @Test\n    void commentInCheckedLine(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var committer = credentials.getHostedRepository();\n\n            var census = credentials.getCensusBuilder()\n                .addCommitter(committer.forge().currentUser().id())\n                .build();\n            var seedPath = tmp.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(committer)\n                    .censusRepo(census)\n                    .seedStorage(seedPath)\n                    .requiredCheckedLines(List.of(\"foo\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tmp.path(), committer.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, committer.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with HTML comment in checked line\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, committer.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(committer, \"master\", \"edit\", \"A PR\",\n                List.of(\"- [<!--x-->] foo\")\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // PR should not be ready for review\n            var updatedPR = committer.pullRequest(pr.id());\n            assertEquals(List.of(), updatedPR.labelNames());\n        }\n    }\n\n    @Test\n    void blockCommentStartingOnCheckedLine(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var committer = credentials.getHostedRepository();\n\n            var census = credentials.getCensusBuilder()\n                .addCommitter(committer.forge().currentUser().id())\n                .build();\n            var seedPath = tmp.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(committer)\n                    .censusRepo(census)\n                    .seedStorage(seedPath)\n                    .requiredCheckedLines(List.of(\"foo\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tmp.path(), committer.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, committer.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with HTML block comment starting on checked line\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, committer.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(committer, \"master\", \"edit\", \"A PR\",\n                List.of(\"- [x] foo <!--\")\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // PR should not be ready for review\n            var updatedPR = committer.pullRequest(pr.id());\n            assertEquals(List.of(), updatedPR.labelNames());\n        }\n    }\n\n    @Test\n    void blockCommentEndingOnCheckedLine(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var committer = credentials.getHostedRepository();\n\n            var census = credentials.getCensusBuilder()\n                .addCommitter(committer.forge().currentUser().id())\n                .build();\n            var seedPath = tmp.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(committer)\n                    .censusRepo(census)\n                    .seedStorage(seedPath)\n                    .requiredCheckedLines(List.of(\"foo\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tmp.path(), committer.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, committer.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with HTML block comment ending on checked line\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, committer.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(committer, \"master\", \"edit\", \"A PR\",\n                List.of(\"<!--\",\n                        \"-->- [x] foo\")\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // PR should not be ready for review\n            var updatedPR = committer.pullRequest(pr.id());\n            assertEquals(List.of(), updatedPR.labelNames());\n        }\n    }\n\n    @Test\n    void commentBeforeCheckedLine(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var committer = credentials.getHostedRepository();\n\n            var census = credentials.getCensusBuilder()\n                .addCommitter(committer.forge().currentUser().id())\n                .build();\n            var seedPath = tmp.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(committer)\n                    .censusRepo(census)\n                    .seedStorage(seedPath)\n                    .requiredCheckedLines(List.of(\"foo\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tmp.path(), committer.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, committer.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with HTML comment on same line\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, committer.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(committer, \"master\", \"edit\", \"A PR\",\n                List.of(\"<!-- HIDDEN -->- [x] foo\")\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // PR should not be ready for review\n            var updatedPR = committer.pullRequest(pr.id());\n            assertEquals(List.of(), updatedPR.labelNames());\n        }\n    }\n\n    @Test\n    void commentsBeforeAndAfterCheckedLineShouldWork(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var committer = credentials.getHostedRepository();\n\n            var census = credentials.getCensusBuilder()\n                .addCommitter(committer.forge().currentUser().id())\n                .build();\n            var seedPath = tmp.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(committer)\n                    .censusRepo(census)\n                    .seedStorage(seedPath)\n                    .requiredCheckedLines(List.of(\"foo\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tmp.path(), committer.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, committer.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with HTML comments before and after checked line\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, committer.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(committer, \"master\", \"edit\", \"A PR\",\n                List.of(\"<!-- BEFORE -->\",\n                        \"<!--\",\n                        \"-->\",\n                        \"- [x] foo\",\n                        \"<!-- AFTER -->\",\n                        \"<!--\",\n                        \"-->\"\n                )\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // PR should be ready for review\n            var updatedPR = committer.pullRequest(pr.id());\n            assertEquals(List.of(\"rfr\"), updatedPR.labelNames());\n        }\n    }\n\n    @Test\n    void blockCommentStartingOnEndingLine(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var committer = credentials.getHostedRepository();\n\n            var census = credentials.getCensusBuilder()\n                .addCommitter(committer.forge().currentUser().id())\n                .build();\n            var seedPath = tmp.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(committer)\n                    .censusRepo(census)\n                    .seedStorage(seedPath)\n                    .requiredCheckedLines(List.of(\"foo\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tmp.path(), committer.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, committer.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with HTML block comment starting on\n            // the same line where a previous HTML block comment ended\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, committer.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(committer, \"master\", \"edit\", \"A PR\",\n                List.of(\"<!--\",\n                        \"--><!--\",\n                        \"- [x] foo\",\n                        \"-->\"\n                )\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // PR should not be ready for review\n            var updatedPR = committer.pullRequest(pr.id());\n            assertEquals(List.of(), updatedPR.labelNames());\n        }\n    }\n\n    @Test\n    void prefixWhitespaceIsNotTrimmed(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var committer = credentials.getHostedRepository();\n\n            var census = credentials.getCensusBuilder()\n                .addCommitter(committer.forge().currentUser().id())\n                .build();\n            var seedPath = tmp.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                    .repo(committer)\n                    .censusRepo(census)\n                    .seedStorage(seedPath)\n                    .requiredCheckedLines(List.of(\"foo\"))\n                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tmp.path(), committer.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, committer.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR with whitespace before checked line\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, committer.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(committer, \"master\", \"edit\", \"A PR\",\n                List.of(\"  - [x] foo\")\n            );\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // PR should not be ready for review\n            var updatedPR = committer.pullRequest(pr.id());\n            assertEquals(List.of(), updatedPR.labelNames());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/ReviewerTests.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.forge.Review;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.Repository;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertFirstCommentContains;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\nclass ReviewerTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var extra = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addAuthor(extra.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue an invalid command\n            pr.addComment(\"/reviewer hello\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr,\"Syntax\");\n\n            // Add a reviewer\n            pr.addComment(\"/reviewer credit @\" + integrator.forge().currentUser().username());\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should not yet consider the PR ready\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            // Remove it again\n            pr.addComment(\"/reviewer remove @\" + integrator.forge().currentUser().username());\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"successfully removed\");\n\n            // Remove something that isn't there\n            pr.addComment(\"/reviewer remove @\" + integrator.forge().currentUser().username());\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr,\"There are no manually specified reviewers associated with this pull request\");\n\n            // Add the reviewer again\n            pr.addComment(\"/reviewer credit integrationreviewer1\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // But also add the review the old-fashioned way\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The commit message preview should contain the reviewer once\n            var creditLine = pr.comments().stream()\n                               .flatMap(comment -> comment.body().lines())\n                               .filter(line -> line.contains(\"Reviewed-by\"))\n                               .findAny()\n                               .orElseThrow();\n            assertEquals(\"Reviewed-by: integrationreviewer1\", creditLine);\n\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"change now passes all *automated*\"))\n                           .count();\n            assertEquals(1, pushed);\n\n            // Add a second reviewer\n            pr.addComment(\"/reviewer credit integrationauthor2\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            creditLine = pr.comments().stream()\n                           .flatMap(comment -> comment.body().lines())\n                           .filter(line -> line.contains(\"Reviewed-by\"))\n                           .findAny()\n                           .orElseThrow();\n            assertEquals(\"Reviewed-by: integrationreviewer1, integrationauthor2\", creditLine);\n\n            // Integrate\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an ok message\n            assertLastCommentContains(pr,\"Pushed as commit\");\n\n            // The change should now be present on the master branch\n            var pushedFolder = tempFolder.path().resolve(\"pushed\");\n            var pushedRepo = Repository.materialize(pushedFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n\n            // The contributor should be credited\n            creditLine = headCommit.message().stream()\n                    .filter(line -> line.contains(\"Reviewed-by\"))\n                    .findAny()\n                    .orElseThrow();\n            assertEquals(\"Reviewed-by: integrationreviewer1, integrationauthor2\", creditLine);\n        }\n    }\n\n    @Test\n    void invalidCommandAuthor(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var external = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue a contributor command not as the PR author\n            var externalPr = external.pullRequest(pr.id());\n            externalPr.addComment(\"/reviewer credit integrationauthor1\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"Only the author\"))\n                          .count();\n            assertEquals(1, error);\n        }\n    }\n\n    @Test\n    void invalidReviewer(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Use a full name\n            pr.addComment(\"/reviewer credit Moo <Foo.Bar (at) host.com>\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Could not parse `Moo` as a valid reviewer\");\n\n            // Empty platform id\n            pr.addComment(\"/reviewer credit @\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Could not parse `@` as a valid reviewer\");\n\n            // Unknown platform id\n            pr.addComment(\"/reviewer credit @someone\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Could not parse `@someone` as a valid reviewer\");\n\n            // Unknown openjdk user\n            pr.addComment(\"/reviewer credit someone\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Could not parse `someone` as a valid reviewer\");\n        }\n    }\n\n    @Test\n    void platformUser(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Use a platform name\n            pr.addComment(\"/reviewer credit @\" + author.forge().currentUser().username());\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply\n            assertLastCommentContains(pr, \"Reviewer `integrationcommitter2` successfully credited.\");\n        }\n    }\n\n    @Test\n    void openJdkUser(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Use a platform name\n            pr.addComment(\"/reviewer credit integrationauthor1\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply\n            assertLastCommentContains(pr, \"Reviewer `integrationauthor1` successfully credited.\");\n        }\n    }\n\n    @Test\n    void removeReviewer(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Remove a reviewer that hasn't been added\n            pr.addComment(\"/reviewer remove integrationauthor1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"There are no manually specified reviewers associated with this pull request.\");\n\n            // Add a reviewer\n            pr.addComment(\"/reviewer credit integrationauthor1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"successfully credited.\");\n\n            // Remove another (not added) reviewer\n            pr.addComment(\"/reviewer remove integrationcommitter2\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Reviewer `integrationcommitter2` was not found.\");\n            assertLastCommentContains(pr, \"Current credited reviewers are:\");\n            assertLastCommentContains(pr, \"- `integrationauthor1`\");\n\n            // Remove an existing reviewer\n            pr.addComment(\"/reviewer remove integrationauthor1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"successfully removed.\");\n        }\n    }\n\n    @Test\n    void prBodyUpdates(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Add a reviewer\n            pr.addComment(\"/reviewer credit integrationauthor1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"successfully credited.\");\n\n            // Verify that body is updated\n            var body = pr.store().body().split(\"\\n\");\n            var contributorsHeaderIndex = -1;\n            for (var i = 0; i < body.length; i++) {\n                var line = body[i];\n                if (line.equals(\"### Reviewers\")) {\n                    contributorsHeaderIndex = i;\n                    break;\n                }\n            }\n            assertNotEquals(contributorsHeaderIndex, -1);\n            var contributors = new ArrayList<String>();\n            for (var i = contributorsHeaderIndex + 1; i < body.length && body[i].startsWith(\" * \"); i++) {\n                contributors.add(body[i].substring(3));\n            }\n            assertEquals(1, contributors.size());\n            assertEquals(\"Generated Author 1 - Author ⚠️ Added manually\", contributors.get(0));\n\n            // Remove reviewer\n            pr.addComment(\"/reviewer remove integrationauthor1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"successfully removed.\");\n\n            // Verify that body does not contain a \"Reviewers\" section\n            for (var line : pr.store().body().split(\"\\n\")) {\n                assertNotEquals(\"### Reviewers\", line);\n            }\n            assertFalse(pr.store().body().contains(\"Added manually\"));\n\n            // Add it once more\n            pr.addComment(\"/reviewer credit integrationauthor1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"successfully credited.\");\n            assertTrue(pr.store().body().contains(\" * Generated Author 1 - Author ⚠️ Added manually\"));\n\n            // Now add an authenticated review from the same reviewer\n            var integratorPr = integrator.pullRequest(pr.id());\n            integratorPr.addReview(Review.Verdict.APPROVED, \"Looks good\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The reviewer should no longer be listed as added manually\n            assertFalse(pr.store().body().contains(\"Added manually\"));\n            assertTrue(pr.store().body().contains(\" * Generated Author 1 (@user2 - Author)\"));\n        }\n    }\n\n    @Test\n    void addAuthenticated(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var extra = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addAuthor(extra.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Add the review the old-fashioned way\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The reviewer is not added manually\n            assertFalse(pr.store().body().contains(\"Added manually\"));\n            assertTrue(pr.store().body().contains(\" * Generated Reviewer 1 (@user2 - **Reviewer**)\"));\n\n            // Try to add it manually as well\n            pr.addComment(\"/reviewer credit integrationreviewer1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr,\"Reviewer `integrationreviewer1` has already made an authenticated review of this PR\");\n\n            // The reviewer is not added manually\n            assertFalse(pr.store().body().contains(\"Added manually\"));\n            assertTrue(pr.store().body().contains(\" * Generated Reviewer 1 (@user2 - **Reviewer**)\"));\n        }\n    }\n\n    @Test\n    void multiple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var extra = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(extra.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Credit two additional reviewers\n            pr.addComment(\"/reviewer credit integrationreviewer1 integrationcommitter3\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Check the PR body\n            assertTrue(pr.store().body().contains(\" * Generated Reviewer 1 - **Reviewer** ⚠️ Added manually\"));\n            assertTrue(pr.store().body().contains(\" * Generated Committer 3 - Committer ⚠️ Added manually\"));\n\n            // Add a real review\n            var approvalPr = extra.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Check the ready comment\n            assertFirstCommentContains(pr, \"Reviewed-by: integrationreviewer2, integrationreviewer1, integrationcommitter3\");\n\n            // Check the PR body\n            assertTrue(pr.store().body().contains(\" * Generated Reviewer 1 - **Reviewer** ⚠️ Added manually\"));\n            assertTrue(pr.store().body().contains(\" * Generated Committer 3 - Committer ⚠️ Added manually\"));\n            assertTrue(pr.store().body().contains(\" * Generated Reviewer 2 (@user3 - **Reviewer**)\"));\n            assertFalse(pr.store().body().contains(\" * Generated Reviewer 2 (@user3 - **Reviewer**) ⚠️ Added manually\"));\n\n            // Remove both reviewers\n            pr.addComment(\"/reviewer remove integrationreviewer1 integrationcommitter3\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Expect success\n            assertLastCommentContains(pr, \"Reviewer `integrationreviewer1` successfully removed\");\n            assertLastCommentContains(pr, \"Reviewer `integrationcommitter3` successfully removed\");\n\n            pr.addComment(\"/reviewer credit integrationreviewer2\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr,\n                    \"Reviewer `integrationreviewer2` has already made an authenticated review of this PR, \" +\n                            \"and does not need to be credited manually.\");\n\n            var reviewPr = integrator.pullRequest(pr.id());\n            reviewPr.addReview(Review.Verdict.NONE, \"My comments\");\n            pr.addComment(\"/reviewer credit integrationreviewer1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr,\n                    \"Reviewer `integrationreviewer1` has already made an authenticated review of this PR, \" +\n                            \"but did not approve it. Manually crediting them is not allowed.\");\n\n            // Check the PR body\n            assertFalse(pr.store().body().contains(\" * Generated Reviewer 1 - **Reviewer**\"));\n            assertFalse(pr.store().body().contains(\" * Generated Committer 3 - Committer\"));\n            assertTrue(pr.store().body().contains(\" * Generated Reviewer 2 (@user3 - **Reviewer**)\"));\n            assertFalse(pr.store().body().contains(\" * Generated Reviewer 2 (@user3 - **Reviewer**) ⚠️ Added manually\"));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/ReviewersTests.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.Review;\nimport org.openjdk.skara.test.*;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\nimport static org.openjdk.skara.jcheck.ReviewersConfiguration.BYLAWS_URL;\n\npublic class ReviewersTests {\n    private static final String REVIEWERS_COMMENT_TEMPLATE = \"The total number of required reviews for this PR \" +\n            \"(including the jcheck configuration and the last /reviewers command) is now set to %d (with at least %s).\";\n    private static final String ZERO_REVIEWER_COMMENT = \"The total number of required reviews for this PR \" +\n            \"(including the jcheck configuration and the last /reviewers command) is now set to 0.\";\n\n    private static final String REVIEW_PROGRESS_TEMPLATE = \"Change must be properly reviewed (%d review%s required, with at least %s)\";\n    private static final String ZERO_REVIEW_PROGRESS = \"Change must be properly reviewed (no review required)\";\n\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(bot).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            var reviewerPr = (TestPullRequest)integrator.pullRequest(pr.id());\n\n            // No arguments\n            reviewerPr.addComment(\"/reviewers\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a help message\n            assertLastCommentContains(reviewerPr,\"is the number of required reviewers\");\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n\n            // Invalid syntax\n            reviewerPr.addComment(\"/reviewers two\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a help message\n            assertLastCommentContains(reviewerPr,\"is the number of required reviewers\");\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n\n            // Too many\n            reviewerPr.addComment(\"/reviewers 7001\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(reviewerPr,\"Cannot increase the required number of reviewers above 10 (requested: 7001)\");\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n\n            // Too few\n            reviewerPr.addComment(\"/reviewers -3\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(reviewerPr,\"Cannot decrease the required number of reviewers below 0 (requested: -3)\");\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n\n            // Unknown role\n            reviewerPr.addComment(\"/reviewers 2 penguins\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(reviewerPr,\"Unknown role `penguins` specified\");\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n\n            // Set the number\n            reviewerPr.addComment(\"/reviewers 2\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(reviewerPr, getReviewersExpectedComment(0, 1, 0, 1, 0));\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 1, 0)));\n\n            // Set 2 of role committers\n            reviewerPr.addComment(\"/reviewers 2 committer\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(reviewerPr, getReviewersExpectedComment(0, 1, 1, 0, 0));\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 1, 0, 0)));\n\n            // Set 2 of role reviewers\n            reviewerPr.addComment(\"/reviewers 2 reviewer\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(reviewerPr, getReviewersExpectedComment(0, 2, 0, 0, 0));\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 2, 0, 0, 0)));\n\n            // Approve it as another user\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The PR should not yet be considered as ready for review\n            var updatedPr = author.pullRequest(pr.id());\n            assertFalse(updatedPr.labelNames().contains(\"ready\"));\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 2, 0, 0, 0)));\n\n            // Now reduce the number of required reviewers\n            reviewerPr.addComment(\"/reviewers 1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The PR should now be considered as ready for review\n            updatedPr = author.pullRequest(pr.id());\n            assertTrue(updatedPr.labelNames().contains(\"ready\"));\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n\n            // Now request that the lead reviews\n            reviewerPr.addComment(\"/reviewers 1 lead\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(reviewerPr, getReviewersExpectedComment(1, 0, 0, 0, 0));\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(1, 0, 0, 0, 0)));\n\n            // The PR should no longer be considered as ready for review\n            updatedPr = author.pullRequest(pr.id());\n            assertFalse(updatedPr.labelNames().contains(\"ready\"));\n\n            // Drop the extra requirement that it should be the lead\n            reviewerPr.addComment(\"/reviewers 1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(reviewerPr, getReviewersExpectedComment(0, 1, 0, 0, 0));\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n\n            // The PR should now be considered as ready for review yet again\n            updatedPr = author.pullRequest(pr.id());\n            assertTrue(updatedPr.labelNames().contains(\"ready\"));\n        }\n    }\n\n    @Test\n    void noIntegration(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(bot).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            var reviewerPr = (TestPullRequest) integrator.pullRequest(pr.id());\n\n            // Set the number\n            reviewerPr.addComment(\"/reviewers 2\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(reviewerPr, getReviewersExpectedComment(0, 1, 0, 1, 0));\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 1, 0)));\n\n            // Approve it as another user\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // It should not be possible to integrate yet\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(reviewerPr,\"pull request has not yet been marked as ready for integration\");\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 1, 0)));\n\n            // Relax the requirement\n            reviewerPr.addComment(\"/reviewers 1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n\n            // It should now work fine\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(reviewerPr,\"Pushed as commit\");\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n        }\n    }\n\n    @Test\n    void noSponsoring(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addAuthor(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(bot).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            var reviewerPr = (TestPullRequest)integrator.pullRequest(pr.id());\n\n            // Approve it as another user\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n\n            // Flag it as ready for integration\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(reviewerPr,\"now ready to be sponsored\");\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n\n            // Set the number\n            reviewerPr.addComment(\"/reviewers 2\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(reviewerPr, getReviewersExpectedComment(0, 1, 0, 1, 0));\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 1, 0)));\n\n            // It should not be possible to sponsor\n            reviewerPr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(reviewerPr,\"PR has not yet been marked as ready for integration\");\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 1, 0)));\n\n            // Relax the requirement\n            reviewerPr.addComment(\"/reviewers 1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n\n            // It should now work fine\n            reviewerPr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(reviewerPr,\"Pushed as commit\");\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n        }\n    }\n\n    @Test\n    void prAuthorShouldBeAllowedToExecute(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(bot).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            var authorPR = (TestPullRequest)author.pullRequest(pr.id());\n\n            // The author deems that two reviewers are required\n            authorPR.addComment(\"/reviewers 2\");\n\n            // The bot should reply with a success message\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(authorPR, getReviewersExpectedComment(0, 1, 0, 1, 0));\n            assertTrue(authorPR.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 1, 0)));\n        }\n    }\n\n    @Test\n    void prAuthorShouldNotBeAllowedToDecrease(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addAuthor(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(bot).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            var authorPR = (TestPullRequest)author.pullRequest(pr.id());\n\n            // The author deems that two reviewers are required\n            authorPR.addComment(\"/reviewers 2\");\n\n            // The bot should reply with a success message\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(authorPR, getReviewersExpectedComment(0, 1, 0, 1, 0));\n            assertTrue(authorPR.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 1, 0)));\n            // The author should not be allowed to decrease even its own /reviewers command\n            authorPR.addComment(\"/reviewers 1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(authorPR, \"Only [Reviewers](https://openjdk.org/bylaws#reviewer) \"\n                    + \"are allowed to decrease the number of required reviewers.\");\n            assertTrue(authorPR.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 1, 0)));\n\n            // Reviewer should be allowed to decrease\n            var reviewerPr = (TestPullRequest)integrator.pullRequest(pr.id());\n            reviewerPr.addComment(\"/reviewers 1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(reviewerPr, getReviewersExpectedComment(0, 1, 0, 0, 0));\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n\n            // The author should not be allowed to lower the role of the reviewers\n            authorPR.addComment(\"/reviewers 1 contributors\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(authorPR, \"Only [Reviewers](https://openjdk.org/bylaws#reviewer) \"\n                    + \"are allowed to lower the role for additional reviewers.\");\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n\n            // Reviewer should be allowed to lower the role of the reviewers\n            reviewerPr.addComment(\"/reviewers 1 contributors\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(reviewerPr, getReviewersExpectedComment(0, 1, 0, 0, 0));\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 0, 0)));\n        }\n    }\n\n    @Test\n    void commandInPRBody(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(bot).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\", List.of(\"/reviewers 2\"));\n\n            TestBotRunner.runPeriodicItems(prBot);\n\n            var authorPR = (TestPullRequest)author.pullRequest(pr.id());\n            assertLastCommentContains(authorPR, getReviewersExpectedComment(0, 1, 0, 1, 0));\n            assertTrue(authorPR.store().body().contains(getReviewersExpectedProgress(0, 1, 0, 1, 0)));\n        }\n    }\n\n    @Test\n    void complexCombinedConfigAndCommand(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(bot).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Change the jcheck configuration\n            var confPath = localRepo.root().resolve(\".jcheck/conf\");\n            var defaultConf = Files.readString(confPath);\n            var newConf = defaultConf.replace(\"reviewers=1\", \"\"\"\n                                                    lead=1\n                                                    reviewers=1\n                                                    committers=1\n                                                    authors=1\n                                                    contributors=1\n                                                    ignore=duke\n                                                    \"\"\");\n            Files.writeString(confPath, newConf);\n            localRepo.add(confPath);\n            var confHash = localRepo.commit(\"Change conf\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            var reviewerPr = (TestPullRequest)integrator.pullRequest(pr.id());\n\n            // test role contributor\n            for (int i = 1; i <= 10; i++) {\n                var contributorNum = (i < 6) ? 1 : i - 4;\n                verifyReviewersCommentAndProgress(reviewerPr, prBot, \"/reviewers \" + i + \" contributor\",\n                        getReviewersExpectedComment(1, 1, 1, 1, contributorNum),\n                        getReviewersExpectedProgress(1, 1, 1, 1, contributorNum));\n            }\n\n            // test role author\n            for (int i = 1; i <= 10; i++) {\n                var contributorNum = (i < 5) ? 1 : 0;\n                var authorNum = (i < 5) ? 1 : i - 3;\n                verifyReviewersCommentAndProgress(reviewerPr, prBot, \"/reviewers \" + i + \" author\",\n                        getReviewersExpectedComment(1, 1, 1, authorNum, contributorNum),\n                        getReviewersExpectedProgress(1, 1, 1, authorNum, contributorNum));\n            }\n\n            // test role committer\n            for (int i = 1; i <= 10; i++) {\n                var contributorNum = (i < 4) ? 1 : 0;\n                var authorNum = (i < 5) ? 1 : 0;\n                var committerNum = (i < 4) ? 1 : i - 2;\n                verifyReviewersCommentAndProgress(reviewerPr, prBot, \"/reviewers \" + i + \" committer\",\n                        getReviewersExpectedComment(1, 1, committerNum, authorNum, contributorNum),\n                        getReviewersExpectedProgress(1, 1, committerNum, authorNum, contributorNum));\n            }\n\n            // test role reviewer\n            for (int i = 1; i <= 10; i++) {\n                var contributorNum = (i < 3) ? 1 : 0;\n                var authorNum = (i < 4) ? 1 : 0;\n                var committerNum = (i < 5) ? 1 : 0;\n                var reviewerNum = (i < 3) ? 1 : i - 1;\n                verifyReviewersCommentAndProgress(reviewerPr, prBot, \"/reviewers \" + i + \" reviewer\",\n                        getReviewersExpectedComment(1, reviewerNum, committerNum, authorNum, contributorNum),\n                        getReviewersExpectedProgress(1, reviewerNum, committerNum, authorNum, contributorNum));\n            }\n\n            // test role lead\n            verifyReviewersCommentAndProgress(reviewerPr, prBot, \"/reviewers 1 lead\",\n                    getReviewersExpectedComment(1, 1, 1, 1, 1),\n                    getReviewersExpectedProgress(1, 1, 1, 1, 1));\n        }\n    }\n\n    private void verifyReviewersCommentAndProgress(TestPullRequest reviewerPr, PullRequestBot prBot, String command, String expectedComment, String expectedProgress) throws IOException {\n        reviewerPr.addComment(command);\n        TestBotRunner.runPeriodicItems(prBot);\n        assertLastCommentContains(reviewerPr, expectedComment);\n        assertTrue(reviewerPr.store().body().contains(expectedProgress));\n    }\n\n    private String getReviewersExpectedComment(int leadNum, int reviewerNum, int committerNum, int authorNum, int contributorNum) {\n        return constructFromTemplate(REVIEWERS_COMMENT_TEMPLATE, ZERO_REVIEWER_COMMENT, leadNum, reviewerNum, committerNum, authorNum, contributorNum);\n    }\n\n    private String getReviewersExpectedProgress(int leadNum, int reviewerNum, int committerNum, int authorNum, int contributorNum) {\n        return constructFromTemplate(REVIEW_PROGRESS_TEMPLATE, ZERO_REVIEW_PROGRESS, leadNum, reviewerNum, committerNum, authorNum, contributorNum);\n    }\n\n    private String constructFromTemplate(String template, String zeroTemplate, int leadNum, int reviewerNum, int committerNum, int authorNum, int contributorNum) {\n        var totalNum = leadNum + reviewerNum + committerNum + authorNum + contributorNum;\n        if (totalNum == 0) {\n            return zeroTemplate;\n        }\n        var requireList = new ArrayList<String>();\n        var reviewRequirementMap = new LinkedHashMap<String, Integer>();\n        reviewRequirementMap.put(\"[Lead%s](%s#project-lead)\", leadNum);\n        reviewRequirementMap.put(\"[Reviewer%s](%s#reviewer)\", reviewerNum);\n        reviewRequirementMap.put(\"[Committer%s](%s#committer)\", committerNum);\n        reviewRequirementMap.put(\"[Author%s](%s#author)\", authorNum);\n        reviewRequirementMap.put(\"[Contributor%s](%s#contributor)\", contributorNum);\n        for (var reviewRequirement : reviewRequirementMap.entrySet()) {\n            var requirementNum = reviewRequirement.getValue();\n            if (requirementNum > 0) {\n                requireList.add(requirementNum + \" \" + String.format(reviewRequirement.getKey(), requirementNum > 1 ? \"s\" : \"\", BYLAWS_URL));\n            }\n        }\n        if (template.equals(REVIEW_PROGRESS_TEMPLATE)) {\n            return String.format(template, totalNum, totalNum > 1 ? \"s\" : \"\", String.join(\", \", requireList));\n        } else {\n            return String.format(template, totalNum, String.join(\", \", requireList));\n        }\n    }\n\n    @Test\n    void testZeroReviewer(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(bot).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Change the jcheck configuration\n            var confPath = localRepo.root().resolve(\".jcheck/conf\");\n            var defaultConf = Files.readString(confPath);\n            var newConf = defaultConf.replace(\"reviewers=1\", \"\"\"\n                                                    lead=0\n                                                    reviewers=0\n                                                    committers=0\n                                                    authors=0\n                                                    contributors=0\n                                                    ignore=duke\n                                                    \"\"\");\n            Files.writeString(confPath, newConf);\n            localRepo.add(confPath);\n            var confHash = localRepo.commit(\"Change conf\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(confHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\", List.of(\"\"));\n            var reviewerPr = (TestPullRequest)reviewer.pullRequest(pr.id());\n\n            TestBotRunner.runPeriodicItems(prBot);\n            var authorPR = author.pullRequest(pr.id());\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 0, 0, 0, 0)));\n\n            authorPR.addComment(\"/reviewers 2 reviewer\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 2, 0, 0, 0)));\n\n            reviewerPr.addComment(\"/reviewers 0\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertTrue(reviewerPr.store().body().contains(getReviewersExpectedProgress(0, 0, 0, 0, 0)));\n        }\n    }\n\n    @Test\n    void testReviewCommentsAfterApprovedReview(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var bot = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addAuthor(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(bot).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            var reviewerPr = integrator.pullRequest(pr.id());\n\n            // Approve it as another user\n            reviewerPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            // The pr should contain 'Ready' label\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n\n            // Add a review comment\n            reviewerPr.addReview(Review.Verdict.NONE, \"Just a comment1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            // The pr should still contain 'Ready' label\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n\n            // Add a review comment\n            reviewerPr.addReview(Review.Verdict.NONE, \"Just a comment2\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            // The pr should still contain 'Ready' label\n            assertTrue(pr.store().labelNames().contains(\"ready\"));\n\n            // Disapprove this pr\n            reviewerPr.addReview(Review.Verdict.DISAPPROVED, \"Disapproved\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            // The pr should not contain 'Ready' label\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n\n            // Add a review comment\n            reviewerPr.addReview(Review.Verdict.NONE, \"Just a comment3\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n            // The pr should still not contain 'Ready' label\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/SponsorTests.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.Review;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.Repository;\nimport org.openjdk.skara.vcs.Branch;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\nclass SponsorTests {\n    private void runSponsortest(TestInfo testInfo, boolean isAuthor) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var pushedFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            if (isAuthor) {\n                censusBuilder.addAuthor(author.forge().currentUser().id());\n            }\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var authorFullName = author.forge().currentUser().fullName();\n            var authorEmail = \"ta@none.none\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"This is a new line\", \"Append commit\", authorFullName, authorEmail);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot see it\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Issue a merge command without being a Committer\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply that a sponsor is required\n            var sponsor = pr.comments().stream()\n                            .filter(comment -> comment.body().contains(\"sponsor\"))\n                            .filter(comment -> comment.body().contains(\"your change\"))\n                            .count();\n            assertEquals(1, sponsor);\n\n            // The bot should not have pushed the commit\n            var notPushed = pr.comments().stream()\n                              .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                              .count();\n            assertEquals(0, notPushed);\n\n            // Reviewer now agrees to sponsor\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should have pushed the commit\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed);\n\n            // The change should now be present on the master branch\n            var pushedRepo = Repository.materialize(pushedFolder.path(), author.authenticatedUrl(), \"master\");\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n\n            if (isAuthor) {\n                assertEquals(\"Generated Author 2\", headCommit.author().name());\n                assertEquals(\"integrationauthor2@openjdk.org\", headCommit.author().email());\n            } else {\n                assertEquals(authorFullName, headCommit.author().name());\n                assertEquals(authorEmail, headCommit.author().email());\n            }\n\n            assertEquals(\"Generated Reviewer 1\", headCommit.committer().name());\n            assertEquals(\"integrationreviewer1@openjdk.org\", headCommit.committer().email());\n            assertTrue(pr.store().labelNames().contains(\"integrated\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            assertFalse(pr.store().labelNames().contains(\"sponsor\"));\n        }\n    }\n\n    @Test\n    void sponsorNonAuthor(TestInfo testInfo) throws IOException {\n        runSponsortest(testInfo, false);\n    }\n\n    @Test\n    void sponsorAuthor(TestInfo testInfo) throws IOException {\n        runSponsortest(testInfo, true);\n    }\n\n    @Test\n    void sponsorNotNeeded(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue an invalid command\n            pr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"does not need sponsoring\"))\n                          .count();\n            assertEquals(1, error);\n        }\n    }\n\n    @Test\n    void sponsorNotAllowed(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue an invalid command\n            pr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"Committers\"))\n                          .filter(comment -> comment.body().contains(\"are allowed to sponsor\"))\n                          .count();\n            assertEquals(1, error);\n        }\n    }\n\n    @Test\n    void sponsorNotReady(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Reviewer now tries to sponsor\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"before the integration can be sponsored\"))\n                          .count();\n            assertEquals(1, error);\n        }\n    }\n\n    @Test\n    void sponsorAfterChanges(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var authorFullName = author.forge().currentUser().fullName();\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"This is a new line\", \"Append commit\", authorFullName, \"ta@none.none\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot see it\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Flag it as ready for integration\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Bot should have replied\n            var ready = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"now ready to be sponsored\"))\n                          .filter(comment -> comment.body().contains(\"at version \" + editHash.hex()))\n                          .count();\n            assertEquals(1, ready);\n            assertTrue(pr.store().labelNames().contains(\"sponsor\"));\n\n            // Push another change\n            var updateHash = CheckableRepository.appendAndCommit(localRepo, \"Yet more stuff\", \"Append commit\", authorFullName, \"ta@none.none\");\n            localRepo.push(updateHash, author.authenticatedUrl(), \"edit\");\n\n            // The label should have been dropped\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertFalse(pr.store().labelNames().contains(\"sponsor\"));\n\n            // Reviewer now tries to sponsor\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"The PR has been updated since the change\"))\n                          .count();\n            assertEquals(1, error);\n\n            // Flag it as ready for integration again\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertTrue(pr.store().labelNames().contains(\"sponsor\"));\n\n            // It should now be possible to sponsor\n            reviewerPr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertFalse(pr.store().labelNames().contains(\"sponsor\"));\n\n            // The bot should have pushed the commit\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed);\n        }\n    }\n\n    @Test\n    void autoRebase(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var pushedFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Push something unrelated to master\n            localRepo.checkout(masterHash, true);\n            var unrelated = localRepo.root().resolve(\"unrelated.txt\");\n            Files.writeString(unrelated, \"Hello\");\n            localRepo.add(unrelated);\n            var unrelatedHash = localRepo.commit(\"Unrelated\", \"X\", \"x@y.z\");\n            localRepo.push(unrelatedHash, author.authenticatedUrl(), \"master\");\n\n            // Issue a merge command without being a Committer\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply that a sponsor is required\n            var sponsor = pr.comments().stream()\n                            .filter(comment -> comment.body().contains(\"sponsor\"))\n                            .filter(comment -> comment.body().contains(\"your change\"))\n                            .count();\n            assertEquals(1, sponsor);\n\n            // Reviewer now agrees to sponsor\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var prePush = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Going to push as commit\"))\n                    .filter(comment -> comment.body().contains(\"commit was automatically rebased without conflicts\"))\n                    .count();\n            assertEquals(1, prePush);\n            var pushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .count();\n            assertEquals(1, pushed);\n\n            // The change should now be present on the master branch\n            var pushedRepo = Repository.materialize(pushedFolder.path(), author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n        }\n    }\n\n    @Test\n    void noAutoRebase(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var pushedFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Push something unrelated to master\n            localRepo.checkout(masterHash, true);\n            var unrelated = localRepo.root().resolve(\"unrelated.txt\");\n            Files.writeString(unrelated, \"Hello\");\n            localRepo.add(unrelated);\n            var unrelatedHash = localRepo.commit(\"Unrelated\", \"X\", \"x@y.z\");\n            localRepo.push(unrelatedHash, author.authenticatedUrl(), \"master\");\n\n            // Issue a merge command without being a Committer\n            pr.addComment(\"/integrate \" + masterHash);\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr, \"the target branch is no longer at the requested hash\");\n\n            // Now choose the actual hash\n            pr.addComment(\"/integrate \" + unrelatedHash);\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply that a sponsor is required\n            assertLastCommentContains(pr, \"your sponsor will make the final decision onto which target hash to integrate\");\n\n            // Push more unrelated things\n            Files.writeString(unrelated, \"Hello again\");\n            localRepo.add(unrelated);\n            var unrelatedHash2 = localRepo.commit(\"Unrelated 2\", \"X\", \"x@y.z\");\n            localRepo.push(unrelatedHash2, author.authenticatedUrl(), \"master\");\n\n            // Reviewer now agrees to sponsor\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/sponsor \" + unrelatedHash);\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr, \"head of the target branch is no longer at the requested hash\");\n\n            // Use the current hash\n            reviewerPr.addComment(\"/sponsor \" + unrelatedHash2);\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            assertLastCommentContains(pr, \"Pushed as commit\");\n\n            // The change should now be present on the master branch\n            var pushedRepo = Repository.materialize(pushedFolder.path(), author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n        }\n    }\n\n    @Test\n    void sponsorAfterFailingCheck(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var authorFullName = author.forge().currentUser().fullName();\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"This is a new line\", \"Append commit\", authorFullName, \"ta@none.none\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot see it\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Flag it as ready for integration\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Bot should have replied\n            var ready = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"now ready to be sponsored\"))\n                          .filter(comment -> comment.body().contains(\"at version \" + editHash.hex()))\n                          .count();\n            assertEquals(1, ready);\n            assertTrue(pr.store().labelNames().contains(\"sponsor\"));\n\n            // The reviewer now changes their mind\n            approvalPr.addReview(Review.Verdict.DISAPPROVED, \"No wait, disapproved\");\n\n            // The label should have been dropped\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertFalse(pr.store().labelNames().contains(\"sponsor\"));\n\n            // Reviewer now tries to sponsor\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr, \"PR has not yet been marked as ready for integration\");\n\n            // Make it ready for integration again\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Sorry, wrong button\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertTrue(pr.store().labelNames().contains(\"sponsor\"));\n\n            // It should now be possible to sponsor\n            reviewerPr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertFalse(pr.store().labelNames().contains(\"sponsor\"));\n\n            // The bot should have pushed the commit\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed);\n        }\n    }\n\n    @Test\n    void cannotRebase(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id())\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot see it\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Issue a merge command without being a Committer\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply that a sponsor is required\n            var sponsor = pr.comments().stream()\n                            .filter(comment -> comment.body().contains(\"sponsor\"))\n                            .filter(comment -> comment.body().contains(\"your change\"))\n                            .count();\n            assertEquals(1, sponsor);\n\n            // The bot should not have pushed the commit\n            var notPushed = pr.comments().stream()\n                              .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                              .count();\n            assertEquals(0, notPushed);\n\n            // Push something conflicting to master\n            localRepo.checkout(masterHash, true);\n            var conflictingHash = CheckableRepository.appendAndCommit(localRepo, \"This looks like a conflict\");\n            localRepo.push(conflictingHash, author.authenticatedUrl(), \"master\");\n\n            // Trigger a new check run\n            pr.setBody(pr.body() + \" recheck\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr, \"this pull request can not be integrated\");\n\n            // Reviewer now agrees to sponsor\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/sponsor\");\n\n            // The bot should reply with an error message\n            TestBotRunner.runPeriodicItems(mergeBot);\n            assertLastCommentContains(pr, \"PR has not yet been marked as ready for integration\");\n        }\n    }\n\n    @Test\n    void sponsorMergeCommit(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory(false)) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var reviewerId = reviewer.forge().currentUser().id();\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewerId)\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path().resolve(\"local.git\"), author.repositoryType());\n            var initialHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            var anotherFile = localRepo.root().resolve(\"ANOTHER_FILE.txt\");\n            Files.writeString(anotherFile, \"A string\\n\");\n            localRepo.add(anotherFile);\n            var masterHash = localRepo.commit(\"Another commit\\n\\nReviewed-by: \" + reviewerId, \"duke\", \"duke@openjdk.org\");\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Create a new branch, new commit and publish it\n            var editBranch = localRepo.branch(initialHash, \"edit\");\n            localRepo.checkout(editBranch);\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n\n            // Prepare to merge edit into master\n            localRepo.checkout(new Branch(\"master\"));\n            var editToMasterBranch = localRepo.branch(masterHash, \"edit->master\");\n            localRepo.checkout(editToMasterBranch);\n            localRepo.merge(editHash);\n            var mergeHash = localRepo.commit(\"Merge edit\", \"duke\", \"duke@openjdk.org\");\n            localRepo.push(mergeHash, author.authenticatedUrl(), \"edit->master\", true);\n\n\n            var pr = credentials.createPullRequest(author, \"master\", \"edit->master\", \"Merge edit\");\n\n            // Approve it as another user\n            var approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot see it\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Issue a merge command without being a Committer\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            //System.out.println(pr.comments());\n            //for (var entry : pr.checks(pr.headHash()).entrySet()) {\n            //    System.out.println(entry.getValue().summary().orElseThrow());\n            //}\n\n            // The bot should reply that a sponsor is required\n            var sponsor = pr.comments().stream()\n                            .filter(comment -> comment.body().contains(\"sponsor\"))\n                            .filter(comment -> comment.body().contains(\"your change\"))\n                            .count();\n            assertEquals(1, sponsor);\n\n            // The bot should not have pushed the commit\n            var notPushed = pr.comments().stream()\n                              .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                              .count();\n            assertEquals(0, notPushed);\n\n            // Reviewer now agrees to sponsor\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should have pushed the commit\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed);\n\n            var targetRepo = Repository.clone(author.authenticatedUrl(), tempFolder.path().resolve(\"target.git\"));\n            var masterHead = targetRepo.lookup(new Branch(\"origin/master\")).orElseThrow();\n            assertEquals(\"Merge edit\", masterHead.message().get(0));\n        }\n    }\n\n    @Test\n    void sponsorAutoIntegration(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var authorFullName = author.forge().currentUser().fullName();\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"This is a new line\", \"Append commit\", authorFullName, \"ta@none.none\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Flag it as ready for integration automatically\n            pr.addComment(\"/integrate auto\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Bot should have replied\n            var replies = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"will be automatically integrated\"))\n                          .count();\n            assertEquals(1, replies);\n\n            // Approve it as another user\n            var approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot see it, needs two runs to first mark ready, then add /integrate\n            TestBotRunner.runPeriodicItems(mergeBot);\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Bot should have marked the PR as ready for sponsor\n            var ready = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"now ready to be sponsored\"))\n                          .filter(comment -> comment.body().contains(\"at version \" + editHash.hex()))\n                          .count();\n            assertEquals(1, ready);\n            assertTrue(pr.store().labelNames().contains(\"sponsor\"));\n\n            // Reviewer now sponsor\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should have pushed the commit\n            var pushed = pr.comments().stream()\n                           .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                           .count();\n            assertEquals(1, pushed);\n        }\n    }\n\n    @Test\n    void sponsorAutoIntegrationOutOfOrder(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var authorFullName = author.forge().currentUser().fullName();\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"This is a new line\", \"Append commit\", authorFullName, \"ta@none.none\");\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Flag it as ready for integration automatically\n            pr.addComment(\"/integrate auto\");\n            // Reviewer now sponsor a bit too early\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Bot should have replied\n            var replies = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"will be automatically integrated\"))\n                    .count();\n            assertEquals(1, replies);\n\n            // Bot should have replied that sponsoring wasn't yet possible\n            var sponsorReply = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"The PR is not yet marked as ready to be sponsored\"))\n                    .count();\n            assertEquals(1, sponsorReply);\n\n            // Bot should have marked the PR as ready for sponsor\n            var ready = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"now ready to be sponsored\"))\n                    .filter(comment -> comment.body().contains(\"at version \" + editHash.hex()))\n                    .count();\n            assertEquals(1, ready);\n\n            // Try sponsor again\n            reviewerPr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should have pushed the commit\n            var pushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .count();\n            assertEquals(1, pushed);\n        }\n    }\n\n\n    /**\n     * Tests recovery after successfully pushing the commit, but failing to update the PR\n     */\n    @Test\n    void retryAfterInterrupt(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addAuthor(author.forge().currentUser().id());\n\n            var censusRepo = censusBuilder.build();\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusRepo).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var authorFullName = author.forge().currentUser().fullName();\n            var authorEmail = \"ta@none.none\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"This is a new line\", \"Append commit\", authorFullName, authorEmail);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as another user\n            var approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n\n            // Let the bot check it\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Issue a merge command without being a Committer\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply that a sponsor is required\n            var sponsor = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"sponsor\"))\n                    .filter(comment -> comment.body().contains(\"your change\"))\n                    .count();\n            assertEquals(1, sponsor);\n\n            // The bot should not have pushed the commit\n            var notPushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .count();\n            assertEquals(0, notPushed);\n\n            // Reviewer now agrees to sponsor\n            var reviewerPr = reviewer.pullRequest(pr.id());\n            reviewerPr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Simulate that interruption occurred after prePush comment was added, but before change was\n            // pushed\n            pr.setState(Issue.State.OPEN);\n            pr.removeLabel(\"integrated\");\n            pr.addLabel(\"ready\");\n            pr.addLabel(\"rfr\");\n            pr.addLabel(\"sponsor\");\n            var commitComment = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .findAny().orElseThrow();\n            pr.removeComment(commitComment);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // The bot should now retry\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            var pushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .count();\n            assertEquals(1, pushed, \"Commit comment not found\");\n            assertFalse(pr.store().labelNames().contains(\"ready\"), \"ready label not removed\");\n            assertFalse(pr.store().labelNames().contains(\"rfr\"), \"rfr label not removed\");\n            assertFalse(pr.store().labelNames().contains(\"sponsor\"), \"sponsor label not removed\");\n            assertTrue(pr.store().labelNames().contains(\"integrated\"), \"integrated label not added\");\n\n            // Remove some labels and the commit comment to simulate that last attempt was interrupted\n            // after the push was made and the PR was closed\n            pr.removeLabel(\"integrated\");\n            pr.addLabel(\"ready\");\n            pr.addLabel(\"rfr\");\n            pr.addLabel(\"sponsor\");\n            var commitComment2 = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .findAny().orElseThrow();\n            pr.removeComment(commitComment2);\n\n            // The bot should now retry\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            pushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .count();\n            assertEquals(1, pushed, \"Commit comment not found\");\n            assertFalse(pr.store().labelNames().contains(\"ready\"), \"ready label not removed\");\n            assertFalse(pr.store().labelNames().contains(\"rfr\"), \"rfr label not removed\");\n            assertFalse(pr.store().labelNames().contains(\"sponsor\"), \"sponsor label not removed\");\n            assertTrue(pr.store().labelNames().contains(\"integrated\"), \"integrated label not added\");\n\n            // Simulate that interruption happened just before the commit comment was added\n            var commitComment3 = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .findAny().orElseThrow();\n            pr.removeComment(commitComment3);\n\n            // The bot should now retry\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an ok message\n            pushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .count();\n            assertEquals(1, pushed, \"Commit comment not found\");\n            assertFalse(pr.store().labelNames().contains(\"ready\"), \"ready label not removed\");\n            assertFalse(pr.store().labelNames().contains(\"rfr\"), \"rfr label not removed\");\n            assertFalse(pr.store().labelNames().contains(\"sponsor\"), \"sponsor label not removed\");\n            assertTrue(pr.store().labelNames().contains(\"integrated\"), \"integrated label not added\");\n\n            // Add another command and verify that no further action is taken\n            pr.addComment(\"/integrate\");\n            var numComments = pr.comments().size();\n\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            assertTrue(pr.comments().getLast().body()\n                    .contains(\"can only be used in open pull requests\"));\n        }\n    }\n\n    @Test\n    void sponsorWithAmendingCommitMessage(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory();\n             var pushedFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var reviewer1 = credentials.getHostedRepository();\n            var reviewer2 = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer1.forge().currentUser().id())\n                    .addReviewer(reviewer2.forge().currentUser().id())\n                    .addAuthor(author.forge().currentUser().id());\n\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).useStaleReviews(false).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var authorFullName = author.forge().currentUser().fullName();\n            var authorEmail = \"ta@none.none\";\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"This is a new line\", \"Append commit\", authorFullName, authorEmail);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Approve it as reviewer2\n            var approval2Pr = reviewer2.pullRequest(pr.id());\n            approval2Pr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Make a change with a corresponding PR\n            var updateHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(updateHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Approve it as reviewer1\n            var approval1Pr = reviewer1.pullRequest(pr.id());\n            approval1Pr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // Issue a merge command without being a Committer\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply that a sponsor is required\n            var sponsor = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"sponsor\"))\n                    .filter(comment -> comment.body().contains(\"your change\"))\n                    .count();\n            assertEquals(1, sponsor);\n\n            // The bot should not have pushed the commit\n            var notPushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .count();\n            assertEquals(0, notPushed);\n\n            // Reviewer now agrees to sponsor\n            var reviewer1Pr = reviewer1.pullRequest(pr.id());\n            pr.addComment(\"/summary amendCommitMessage\");\n            reviewer1Pr.addComment(\"/sponsor\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should have pushed the commit\n            var pushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"Pushed as commit\"))\n                    .count();\n            assertEquals(1, pushed);\n\n            // The change should now be present on the master branch\n            var pushedRepo = Repository.materialize(pushedFolder.path(), author.authenticatedUrl(), \"master\");\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n\n            assertEquals(\"Generated Author 3\", headCommit.author().name());\n            assertEquals(\"integrationauthor3@openjdk.org\", headCommit.author().email());\n\n            // The committer should be the sponsor\n            assertEquals(\"Generated Reviewer 1\", headCommit.committer().name());\n            assertEquals(\"integrationreviewer1@openjdk.org\", headCommit.committer().email());\n            assertTrue(pr.store().labelNames().contains(\"integrated\"));\n            assertFalse(pr.store().labelNames().contains(\"ready\"));\n            assertFalse(pr.store().labelNames().contains(\"sponsor\"));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/SummaryTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.forge.Review;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.Repository;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\n\nclass SummaryTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Try setting a summary when none has been set yet\n            pr.addComment(\"/summary\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a help message\n            assertLastCommentContains(pr,\"To set a summary\");\n\n            // Add a summary\n            pr.addComment(\"/summary This is a summary\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"Setting summary to\");\n\n            // Remove it again\n            pr.addComment(\"/summary\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"Removing existing\");\n\n            // Now add one again\n            pr.addComment(\"/summary Yet another summary\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"Setting summary to\");\n\n            // Approve it as another user\n            var approvalPr = integrator.pullRequest(pr.id());\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Now update it\n            pr.addComment(\"/summary Third time is surely the charm\");\n            TestBotRunner.runPeriodicItems(prBot);\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"Updating existing summary\");\n\n            // The commit message preview should contain the final summary\n            var summaryLine = pr.comments().stream()\n                                .flatMap(comment -> comment.body().lines())\n                                .filter(line -> !line.contains(\"/summary\"))\n                                .filter(line -> !line.contains(\"Updating existing\"))\n                                .filter(line -> line.contains(\"Third time\"))\n                                .findAny()\n                                .orElseThrow();\n            assertEquals(\"Third time is surely the charm\", summaryLine);\n\n            // Integrate\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an ok message\n            assertLastCommentContains(pr,\"Pushed as commit\");\n\n            // The change should now be present on the master branch\n            var pushedFolder = tempFolder.path().resolve(\"pushed\");\n            var pushedRepo = Repository.materialize(pushedFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            var headCommit = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex()).asList().get(0);\n\n            // The summary should be present\n            summaryLine = headCommit.message().stream()\n                                   .filter(line -> line.contains(\"Third time\"))\n                                   .findAny()\n                                   .orElseThrow();\n            assertEquals(\"Third time is surely the charm\", summaryLine);\n        }\n    }\n\n    @Test\n    void invalidCommandAuthor(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var external = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id());\n            var mergeBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"refs/heads/edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue a contributor command not as the PR author\n            var externalPr = external.pullRequest(pr.id());\n            externalPr.addComment(\"/summary a summary\");\n            TestBotRunner.runPeriodicItems(mergeBot);\n\n            // The bot should reply with an error message\n            var error = pr.comments().stream()\n                          .filter(comment -> comment.body().contains(\"Only the author\"))\n                          .count();\n            assertEquals(1, error);\n        }\n    }\n\n    @Test\n    void multiline(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Try setting a summary when none has been set yet\n            pr.addComment(\"/summary\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a help message\n            assertLastCommentContains(pr,\"To set a summary\");\n\n            // Add a multi-line summary\n            pr.addComment(\"   /skara  summary\\nFirst line\\nSecond line\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\n                \"Setting summary to:\\n\" +\n                \"\\n\" +\n                \"```\\n\" +\n                \"First line\\n\" +\n                \"Second line\\n\" +\n                \"```\");\n\n            // Remove it again\n            pr.addComment(\"/summary\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"Removing existing\");\n\n            // Now add one again\n            pr.addComment(\"/summary\\nL1\\nL2\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\n                \"Setting summary to:\\n\" +\n                \"\\n\" +\n                \"```\\n\" +\n                \"L1\\n\" +\n                \"L2\\n\" +\n                \"```\");\n\n            // Now update it\n            pr.addComment(\"/summary\\n1L\\n2L\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\n                \"Updating existing summary to:\\n\" +\n                \"\\n\" +\n                \"```\\n\" +\n                \"1L\\n\" +\n                \"2L\\n\" +\n                \"```\");\n\n            // Finally update it to a single line summary\n            pr.addComment(\"/summary single line\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr, \"Updating existing summary to `single line`\");\n        }\n    }\n\n    @Test\n    void precedingBlankLines(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Try setting a summary when none has been set yet\n            pr.addComment(\"/summary\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a help message\n            assertLastCommentContains(pr,\"To set a summary\");\n\n            // Add a multi-line summary with preceding blank lines\n            pr.addComment(\"/summary\\n\\n\\nFirst line\\nSecond line\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\n                \"Setting summary to:\\n\" +\n                \"\\n\" +\n                \"```\\n\" +\n                \"First line\\n\" +\n                \"Second line\\n\" +\n                \"```\");\n        }\n    }\n\n    @Test\n    void trailingBlankLines(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Try setting a summary when none has been set yet\n            pr.addComment(\"/summary\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a help message\n            assertLastCommentContains(pr,\"To set a summary\");\n\n            // Add a multi-line summary with preceding blank lines\n            pr.addComment(\"/summary\\nFirst line\\nSecond line\\n\\n\\n\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\n                \"Setting summary to:\\n\" +\n                \"\\n\" +\n                \"```\\n\" +\n                \"First line\\n\" +\n                \"Second line\\n\" +\n                \"```\");\n        }\n    }\n\n    @Test\n    void singleAndMultiLineSummary(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addReviewer(integrator.forge().currentUser().id())\n                                           .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Try setting a summary when none has been set yet\n            pr.addComment(\"/summary inline\\nnext line\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // This should also be interpreted as a multi-line summary\n            assertLastCommentContains(pr,\n                                      \"Setting summary to:\\n\" +\n                                              \"\\n\" +\n                                              \"```\\n\" +\n                                              \"inline\\n\" +\n                                              \"next line\\n\" +\n                                              \"```\");\n        }\n    }\n\n    @Test\n    void invalidSummaryTest(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(integrator.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder().repo(integrator).censusRepo(censusBuilder.build()).build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            pr.addComment(\"/summary Co-authored-by: user1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Invalid summary\");\n\n            pr.addComment(\"/summary first line\\n Reviewed-by: user1\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Invalid summary\");\n\n            pr.addComment(\"/summary Backport-of: 12434\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Invalid summary\");\n\n            pr.addComment(\"/summary 1642: TITLE\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Invalid summary\");\n\n            pr.addComment(\"/summary normal comment\");\n            TestBotRunner.runPeriodicItems(prBot);\n            assertLastCommentContains(pr, \"Setting summary to `normal comment`\");\n\n            System.out.println(pr.store().comments());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/TagCommitCommandTests.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.Author;\nimport org.openjdk.skara.vcs.Tag;\nimport org.openjdk.skara.vcs.Repository;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.nio.file.Files;\nimport java.nio.file.StandardOpenOption;\nimport java.util.regex.Pattern;\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class TagCommitCommandTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .integrators(Set.of(author.forge().currentUser().username()))\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add a tag command\n            author.addCommitComment(masterHash, \"/skara tag v1.0\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"tag\"));\n            assertTrue(botReply.body().contains(\"was successfully created\"));\n\n            var localAuthorRepoDir = tempFolder.path().resolve(\"author\");\n            var localAuthorRepo = Repository.clone(author.authenticatedUrl(), localAuthorRepoDir);\n            var tags = localAuthorRepo.tags();\n            assertEquals(List.of(new Tag(\"v1.0\")), tags);\n        }\n    }\n\n    @Test\n    void missingTagName(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .integrators(Set.of(author.forge().currentUser().username()))\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add a tag command\n            author.addCommitComment(masterHash, \"/tag\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"Usage: `/tag <name>`\"));\n\n            var localAuthorRepoDir = tempFolder.path().resolve(\"author\");\n            var localAuthorRepo = Repository.clone(author.authenticatedUrl(), localAuthorRepoDir);\n            var tags = localAuthorRepo.tags();\n            assertEquals(List.of(), tags);\n        }\n    }\n\n    @Test\n    void multipleTagNames(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .integrators(Set.of(author.forge().currentUser().username()))\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add a tag command\n            author.addCommitComment(masterHash, \"/tag a b c\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"Usage: `/tag <name>`\"));\n\n            var localAuthorRepoDir = tempFolder.path().resolve(\"author\");\n            var localAuthorRepo = Repository.clone(author.authenticatedUrl(), localAuthorRepoDir);\n            var tags = localAuthorRepo.tags();\n            assertEquals(List.of(), tags);\n        }\n    }\n\n    @Test\n    void existingTag(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .integrators(Set.of(author.forge().currentUser().username()))\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add a tag command\n            author.addCommitComment(masterHash, \"/tag v1.0\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"tag\"));\n            assertTrue(botReply.body().contains(\"was successfully created\"));\n\n            var localAuthorRepoDir = tempFolder.path().resolve(\"author\");\n            var localAuthorRepo = Repository.clone(author.authenticatedUrl(), localAuthorRepoDir);\n            var tags = localAuthorRepo.tags();\n            assertEquals(List.of(new Tag(\"v1.0\")), tags);\n\n            // Make another commit\n            var anotherHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(anotherHash, author.authenticatedUrl(), \"master\", true);\n\n            // Try to re-create an existing tag\n            author.addCommitComment(anotherHash, \"/tag v1.0\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            recentCommitComments = author.recentCommitComments();\n            assertEquals(4, recentCommitComments.size());\n            botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"A tag with name `v1.0` already exists\"));\n            Pattern compilePattern = Pattern.compile(\".*\\\\[.*\\\\]\\\\(.*\\\\).*\", Pattern.MULTILINE | Pattern.DOTALL);\n            assertTrue(compilePattern.matcher(botReply.body()).matches());\n        }\n    }\n\n    @Test\n    void nonIntegrator(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add a tag command\n            author.addCommitComment(masterHash, \"/tag v1.0\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"Only integrators for this repository are allowed to use the `/tag` command\"));\n\n            var localAuthorRepoDir = tempFolder.path().resolve(\"author\");\n            var localAuthorRepo = Repository.clone(author.authenticatedUrl(), localAuthorRepoDir);\n            var tags = localAuthorRepo.tags();\n            assertEquals(List.of(), tags);\n        }\n    }\n\n    @Test\n    void nonConformingTag(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .integrators(Set.of(author.forge().currentUser().username()))\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var jcheckConf = localRepo.root().resolve(\".jcheck\").resolve(\"conf\");\n            Files.write(jcheckConf, List.of(\"[repository]\", \"tags=foo\"), StandardOpenOption.APPEND);\n            localRepo.add(List.of(Path.of(\".jcheck\", \"conf\")));\n            localRepo.commit(\"Added tags spec\", \"testauthor\", \"ta@none.none\");\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add a tag command\n            author.addCommitComment(masterHash, \"/tag bar\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            System.out.println(botReply);\n            assertTrue(botReply.body().contains(\"The given tag name `bar` is not of the form `foo`\"));\n\n            var localAuthorRepoDir = tempFolder.path().resolve(\"author\");\n            var localAuthorRepo = Repository.clone(author.authenticatedUrl(), localAuthorRepoDir);\n            var tags = localAuthorRepo.tags();\n            assertEquals(List.of(), tags);\n        }\n    }\n\n    @Test\n    void metadata(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addAuthor(author.forge().currentUser().id())\n                                           .addReviewer(reviewer.forge().currentUser().id());\n            var seedFolder = tempFolder.path().resolve(\"seed\");\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(author)\n                                    .integrators(Set.of(author.forge().currentUser().username()))\n                                    .censusRepo(censusBuilder.build())\n                                    .censusLink(\"https://census.com/{{contributor}}-profile\")\n                                    .seedStorage(seedFolder)\n                                    .forks(Map.of(author.name(), author))\n                                    .build();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add a tag command\n            author.addCommitComment(masterHash, \"/skara tag v1.0\");\n            TestBotRunner.runPeriodicItems(bot);\n\n            var recentCommitComments = author.recentCommitComments();\n            assertEquals(2, recentCommitComments.size());\n            var botReply = recentCommitComments.get(0);\n            assertTrue(botReply.body().contains(\"tag\"));\n            assertTrue(botReply.body().contains(\"was successfully created\"));\n\n            var localAuthorRepoDir = tempFolder.path().resolve(\"author\");\n            var localAuthorRepo = Repository.clone(author.authenticatedUrl(), localAuthorRepoDir);\n            var tags = localAuthorRepo.tags();\n            assertEquals(List.of(new Tag(\"v1.0\")), tags);\n\n            var tag = localAuthorRepo.annotate(tags.get(0));\n            assertTrue(tag.isPresent());\n            assertEquals(masterHash, tag.get().target());\n            assertEquals(\"v1.0\", tag.get().name());\n            assertEquals(\"Added tag v1.0 for changeset \" + masterHash.abbreviate(), tag.get().message());\n            assertEquals(Author.fromString(\"Generated Author 1 <integrationauthor1@openjdk.org>\"), tag.get().author());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/TemplateCommandTests.java",
    "content": "/*\n * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport org.openjdk.skara.json.JSON;\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\n\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\nimport static org.openjdk.skara.bots.common.PullRequestConstants.PROGRESS_MARKER;\n\npublic class TemplateCommandTests {\n    @Test\n    void templateCommandOnlyAllowedByAuthor(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n            var other = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addCommitter(other.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(integrator)\n                                    .censusRepo(censusBuilder.build())\n                                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tmp.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n            // Check status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Try to issue the \"/template\" PR command, should not work\n            var prAsOther = other.pullRequest(pr.id());\n            prAsOther.addComment(\"/template append\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertLastCommentContains(prAsOther, \"Only the pull request author can append a pull request template\");\n        }\n    }\n\n    @Test\n    void mustSupplyArgument(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(integrator)\n                                    .censusRepo(censusBuilder.build())\n                                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tmp.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            // Check status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Try to issue the \"/template\" PR command without arguments, should not work\n            var updatedPR = author.pullRequest(pr.id());\n            updatedPR.addComment(\"/template\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertLastCommentContains(updatedPR, \"Missing command 'append', usage: `/template append`\");\n        }\n    }\n\n    @Test\n    void argumentMustBeAppend(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(integrator)\n                                    .censusRepo(censusBuilder.build())\n                                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tmp.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            // Check status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Try to issue the \"/template\" PR command with bogus argument, should not work\n            var updatedPR = author.pullRequest(pr.id());\n            updatedPR.addComment(\"/template foo\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertLastCommentContains(updatedPR, \"Unknown argument 'foo', usage: `/template append`\");\n        }\n    }\n\n    @Test\n    void missingPullRequestTemplate(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(integrator)\n                                    .censusRepo(censusBuilder.build())\n                                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tmp.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\");\n\n            // Check status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Try to issue the \"/template append\" PR command, should not work due to missing PR template\n            var updatedPR = author.pullRequest(pr.id());\n            updatedPR.addComment(\"/template append\");\n            TestBotRunner.runPeriodicItems(bot);\n            assertLastCommentContains(updatedPR,\n                \"This repository does not have a pull request template\"\n            );\n        }\n    }\n\n    @Test\n    void gitHubTemplate(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n\n            var issues = credentials.getIssueProject();\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(integrator)\n                                    .issueProject(issues)\n                                    .censusRepo(censusBuilder.build())\n                                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tmp.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add pull request template\n            var prTemplate = localRepo.root().resolve(\".github/pull_request_template.md\");\n            Files.createDirectories(prTemplate.getParent());\n            Files.writeString(prTemplate, \"THIS IS A PR TEMPLATE\");\n            localRepo.add(prTemplate);\n            var issue1 = credentials.createIssue(issues, \"Add PR template\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var prTemplateHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(prTemplateHash, author.authenticatedUrl(), \"refs/heads/master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\",\n                    List.of(\"First line in body\")\n            );\n\n            // Check status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Add the \"/template append\" PR command\n            pr.addComment(\"/template append\");\n\n            // Check status again\n            TestBotRunner.runPeriodicItems(bot);\n\n            // The PR template should have been added to the PR body\n            var updatedPR = author.pullRequest(pr.id());\n            assertLastCommentContains(updatedPR,\n                \"The pull request template has been appended to the pull request body\");\n            var expectedBodyPrefix =\n                \"First line in body\\n\" +\n                \"\\n\" +\n                \"THIS IS A PR TEMPLATE\\n\" +\n                \"\\n\" +\n                PROGRESS_MARKER;\n            assertTrue(updatedPR.body().startsWith(expectedBodyPrefix), updatedPR.body());\n        }\n    }\n\n    @Test\n    void gitLabTemplate(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n\n            var issues = credentials.getIssueProject();\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(integrator)\n                                    .issueProject(issues)\n                                    .censusRepo(censusBuilder.build())\n                                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tmp.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add pull request template\n            var prTemplate =\n                localRepo.root().resolve(\".gitlab/merge_request_templates/default.md\");\n            Files.createDirectories(prTemplate.getParent());\n            Files.writeString(prTemplate, \"THIS IS A MERGE REQUEST TEMPLATE\");\n            localRepo.add(prTemplate);\n            var issue1 = credentials.createIssue(issues, \"Add PR template\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var prTemplateHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(prTemplateHash, author.authenticatedUrl(), \"refs/heads/master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\",\n                    List.of(\"First line in body\")\n            );\n\n            // Check status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Add the \"/template append\" PR command\n            pr.addComment(\"/template append\");\n\n            // Check status again\n            TestBotRunner.runPeriodicItems(bot);\n\n            // The PR template should have been added to the PR body\n            var updatedPR = author.pullRequest(pr.id());\n            assertLastCommentContains(updatedPR,\n                \"The pull request template has been appended to the pull request body\");\n            var expectedBodyPrefix =\n                \"First line in body\\n\" +\n                \"\\n\" +\n                \"THIS IS A MERGE REQUEST TEMPLATE\\n\" +\n                \"\\n\" +\n                PROGRESS_MARKER;\n            assertTrue(updatedPR.body().startsWith(expectedBodyPrefix), updatedPR.body());\n        }\n    }\n\n    @Test\n    void forgeConfiguredTemplate(TestInfo testInfo) throws IOException {\n        var forgeConf =\n            JSON.object().put(\"prTemplate\", JSON.array().add(\"\").add(\"foo\").add(\"bar\"));\n        try (var credentials = new HostCredentials(testInfo, forgeConf);\n             var tmp = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n\n            var issues = credentials.getIssueProject();\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(integrator)\n                                    .issueProject(issues)\n                                    .censusRepo(censusBuilder.build())\n                                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tmp.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\",\n                    List.of(\"First line in body\")\n            );\n\n            // Check status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Add the \"/template append\" PR command\n            pr.addComment(\"/template append\");\n\n            // Check status again\n            TestBotRunner.runPeriodicItems(bot);\n\n            // The PR template should have been added to the PR body\n            var updatedPR = author.pullRequest(pr.id());\n            assertLastCommentContains(updatedPR,\n                \"The pull request template has been appended to the pull request body\");\n            var expectedBodyPrefix =\n                \"First line in body\\n\" +\n                \"\\n\" +\n                \"foo\\n\" +\n                \"bar\\n\" +\n                \"\\n\" +\n                PROGRESS_MARKER;\n            assertTrue(updatedPR.body().startsWith(expectedBodyPrefix), updatedPR.body());\n        }\n    }\n\n    @Test\n    void templateCommandWorksInPRBody(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n\n            var issues = credentials.getIssueProject();\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(integrator)\n                                    .issueProject(issues)\n                                    .censusRepo(censusBuilder.build())\n                                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tmp.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add pull request template\n            var prTemplate = localRepo.root().resolve(\".github/pull_request_template.md\");\n            Files.createDirectories(prTemplate.getParent());\n            Files.writeString(prTemplate, \"THIS IS A PR TEMPLATE\");\n            localRepo.add(prTemplate);\n            var issue1 = credentials.createIssue(issues, \"Add PR template\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var prTemplateHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(prTemplateHash, author.authenticatedUrl(), \"refs/heads/master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\",\n                    List.of(\"First line in body\", \"/template append\")\n            );\n\n            // Check status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // The PR template should have been added to the PR body\n            var updatedPR = author.pullRequest(pr.id());\n            assertLastCommentContains(updatedPR,\n                \"The pull request template has been appended to the pull request body\");\n            var expectedBodyPrefix =\n                \"First line in body\\n\" +\n                \"/template append\\n\" +\n                \"\\n\" +\n                \"THIS IS A PR TEMPLATE\\n\" +\n                \"\\n\" +\n                PROGRESS_MARKER;\n            assertTrue(updatedPR.body().startsWith(expectedBodyPrefix), updatedPR.body());\n        }\n    }\n\n    @Test\n    void templateWithLeadingWhitespace(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n\n            var issues = credentials.getIssueProject();\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(integrator)\n                                    .issueProject(issues)\n                                    .censusRepo(censusBuilder.build())\n                                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tmp.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add pull request template\n            var prTemplate = localRepo.root().resolve(\".github/pull_request_template.md\");\n            Files.createDirectories(prTemplate.getParent());\n            Files.writeString(prTemplate, \"\\n\\n--------\\nTEMPLATE WITH LEADING WHITESPACE\");\n            localRepo.add(prTemplate);\n            var issue1 = credentials.createIssue(issues, \"Add PR template\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var prTemplateHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(prTemplateHash, author.authenticatedUrl(), \"refs/heads/master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\",\n                    List.of(\"First line in body\")\n            );\n\n            // Check status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Add the \"/template append\" PR command\n            pr.addComment(\"/template append\");\n\n            // Check status again\n            TestBotRunner.runPeriodicItems(bot);\n\n            // The PR template should have been added to the PR body\n            var updatedPR = author.pullRequest(pr.id());\n            assertLastCommentContains(updatedPR,\n                \"The pull request template has been appended to the pull request body\");\n            var expectedBodyPrefix =\n                \"First line in body\\n\" +\n                \"\\n\" +\n                \"--------\\n\" +\n                \"TEMPLATE WITH LEADING WHITESPACE\\n\" +\n                \"\\n\" +\n                PROGRESS_MARKER;\n            assertTrue(updatedPR.body().startsWith(expectedBodyPrefix), updatedPR.body());\n        }\n    }\n\n    @Test\n    void templateWithTrailingWhitespace(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n            var integrator = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                                           .addCommitter(author.forge().currentUser().id())\n                                           .addReviewer(integrator.forge().currentUser().id());\n\n            var issues = credentials.getIssueProject();\n            var bot = PullRequestBot.newBuilder()\n                                    .repo(integrator)\n                                    .issueProject(issues)\n                                    .censusRepo(censusBuilder.build())\n                                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tmp.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add pull request template\n            var prTemplate = localRepo.root().resolve(\".github/pull_request_template.md\");\n            Files.createDirectories(prTemplate.getParent());\n            Files.writeString(prTemplate, \"--------\\nTEMPLATE WITH TRAILING WHITESPACE\\n\\n\\t\\n\");\n            localRepo.add(prTemplate);\n            var issue1 = credentials.createIssue(issues, \"Add PR template\");\n            var issue1Number = issue1.id().split(\"-\")[1];\n            var originalMessage = issue1Number + \": An issue\\n\" +\n                                  \"\\n\" +\n                                  \"Reviewed-by: integrationreviewer2\";\n            var prTemplateHash = localRepo.commit(originalMessage, \"integrationcommitter1\", \"integrationcommitter1@openjdk.org\");\n            localRepo.push(prTemplateHash, author.authenticatedUrl(), \"refs/heads/master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"123: This is a pull request\",\n                    List.of(\"First line in body\")\n            );\n\n            // Check status\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Add the \"/template append\" PR command\n            pr.addComment(\"/template append\");\n\n            // Check status again\n            TestBotRunner.runPeriodicItems(bot);\n\n            // The PR template should have been added to the PR body\n            var updatedPR = author.pullRequest(pr.id());\n            assertLastCommentContains(updatedPR,\n                \"The pull request template has been appended to the pull request body\");\n            var expectedBodyPrefix =\n                \"First line in body\\n\" +\n                \"\\n\" +\n                \"--------\\n\" +\n                \"TEMPLATE WITH TRAILING WHITESPACE\\n\" +\n                \"\\n\" +\n                PROGRESS_MARKER;\n            assertTrue(updatedPR.body().startsWith(expectedBodyPrefix), updatedPR.body());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/pr/src/test/java/org/openjdk/skara/bots/pr/TrailersTests.java",
    "content": "/*\n * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.pr;\n\nimport java.io.IOException;\nimport java.io.PrintWriter;\nimport java.io.StringWriter;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.regex.Pattern;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\nimport org.openjdk.skara.forge.Review;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.test.CheckableRepository;\nimport org.openjdk.skara.test.HostCredentials;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestBotRunner;\nimport org.openjdk.skara.test.TestHostedRepository;\nimport org.openjdk.skara.test.TestPullRequest;\nimport org.openjdk.skara.test.TestPullRequestStore;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.Commits;\nimport org.openjdk.skara.vcs.Repository;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.bots.pr.PullRequestAsserts.assertLastCommentContains;\nimport static org.openjdk.skara.test.TestHost.FAKE_REPO;\n\npublic class TrailersTests {\n\n    @Test\n    public void rightUser() {\n        var user1 = new HostUser.Builder().id(17).build();\n        var user2 = new HostUser.Builder().id(4711).build();\n\n        var comments = new ArrayList<Comment>();\n        comments.add(new Comment(\"1\", \"Text \" + Trailers.setTrailerMarker(\"Trailer-1\", \"First value\"),\n                user1, null, null));\n        comments.add(new Comment(\"1\", \"Text \" + Trailers.setTrailerMarker(\"Trailer-1\", \"False value\"),\n                user2, null, null));\n\n        var trailers = Trailers.trailers(user1, comments);\n        assertEquals(1, trailers.size());\n        var trailer1 = trailers.getFirst();\n        assertEquals(\"Trailer-1\", trailer1.key());\n        assertEquals(\"First value\", trailer1.value());\n    }\n\n    @Test\n    public void override() {\n        var user1 = new HostUser.Builder().id(17).build();\n\n        var comments = new ArrayList<Comment>();\n        comments.add(new Comment(\"1\", \"Text \" + Trailers.setTrailerMarker(\"Trailer-1\", \"First value\"),\n                user1, null, null));\n        comments.add(new Comment(\"1\", \"Text \" + Trailers.setTrailerMarker(\"Trailer-1\", \"Second value\") + \" More text\",\n                user1, null, null));\n\n        var trailers = Trailers.trailers(user1, comments);\n        assertEquals(1, trailers.size());\n        var trailer1 = trailers.getFirst();\n        assertEquals(\"Trailer-1\", trailer1.key());\n        assertEquals(\"Second value\", trailer1.value());\n    }\n\n    @Test\n    public void remove() {\n        var user1 = new HostUser.Builder().id(17).build();\n\n        var comments = new ArrayList<Comment>();\n        comments.add(new Comment(\"1\", \"Text \" + Trailers.setTrailerMarker(\"Trailer-1\", \"First value\"),\n                user1, null, null));\n        comments.add(new Comment(\"1\", Trailers.removeTrailerMarker(\"Trailer-1\"),\n                user1, null, null));\n\n        var trailers = Trailers.trailers(user1, comments);\n        assertEquals(0, trailers.size());\n    }\n\n    @Test\n    public void multiple() {\n        var user1 = new HostUser.Builder().id(17).build();\n\n        var comments = new ArrayList<Comment>();\n        comments.add(new Comment(\"1\", \"Text \" + Trailers.setTrailerMarker(\"Trailer-1\", \"First value\"),\n                user1, null, null));\n        comments.add(new Comment(\"1\", \"Text \" + Trailers.setTrailerMarker(\"Trailer-2\", \"1st value\"),\n                user1, null, null));\n        comments.add(new Comment(\"1\", \"Text \" + Trailers.setTrailerMarker(\"Trailer-1\", \"Second value\"),\n                user1, null, null));\n        comments.add(new Comment(\"1\", \"Text \" + Trailers.setTrailerMarker(\"Trailer-2\", \"2nd value\"),\n                user1, null, null));\n\n        var trailers = Trailers.trailers(user1, comments);\n        assertEquals(2, trailers.size());\n        var trailer1 = trailers.getFirst();\n        assertEquals(\"Trailer-1\", trailer1.key());\n        assertEquals(\"Second value\", trailer1.value());\n        var trailer2 = trailers.get(1);\n        assertEquals(\"Trailer-2\", trailer2.key());\n        assertEquals(\"2nd value\", trailer2.value());\n    }\n\n    @Test\n    public void commandSet(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = (TestHostedRepository) credentials.getHostedRepository(FAKE_REPO);\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(repo)\n                    .trailerConfigs(List.of(\n                            new TrailerCommand.TrailerConfig(\"Trailer-1\", \"1\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.SINGLE, List.of(Pattern.compile(\".*\")))))\n                    .build();\n            var author = new HostUser.Builder().id(\"17\").build();\n            var pr = new TestPullRequest(new TestPullRequestStore(null, author, null, List.of(), repo, null, null, false), repo);\n            var command = new CommandInvocation(\"1\", author, new TrailerCommand(), \"trailer\", \"set Trailer-1 foo\", null);\n\n            var reply = new StringWriter();\n            new TrailerCommand().handle(prBot, pr, null, null, command, null, new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"Trailer `Trailer-1` with value `foo` successfully set\"), reply.toString());\n        }\n    }\n\n    @Test\n    public void commandSetListTypeValid(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = (TestHostedRepository) credentials.getHostedRepository(FAKE_REPO);\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(repo)\n                    .trailerConfigs(List.of(\n                            new TrailerCommand.TrailerConfig(\"Trailer-1\", \"1\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.LIST,\n                                    List.of(Pattern.compile(\"foo-[0-9]\"), Pattern.compile(\"bar-[0-9]\")))))\n                    .build();\n            var author = new HostUser.Builder().id(\"17\").build();\n            var pr = new TestPullRequest(new TestPullRequestStore(null, author, null, List.of(), repo, null, null, false), repo);\n            var command = new CommandInvocation(\"1\", author, new TrailerCommand(), \"trailer\", \"set Trailer-1 foo-1, bar-2\", null);\n\n            var reply = new StringWriter();\n            new TrailerCommand().handle(prBot, pr, null, null, command, null, new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"Trailer `Trailer-1` with value `foo-1, bar-2` successfully set\"), reply.toString());\n\n            command = new CommandInvocation(\"2\", author, new TrailerCommand(), \"trailer\", \"set Trailer-1 bar-3\", null);\n            reply = new StringWriter();\n            new TrailerCommand().handle(prBot, pr, null, null, command, null, new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"Trailer `Trailer-1` with value `bar-3` successfully set\"), reply.toString());\n        }\n    }\n\n    @Test\n    public void commandSetListTypeInvalid(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = (TestHostedRepository) credentials.getHostedRepository(FAKE_REPO);\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(repo)\n                    .trailerConfigs(List.of(\n                            new TrailerCommand.TrailerConfig(\"Trailer-1\", \"1\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.LIST,\n                                    List.of(Pattern.compile(\"foo-[0-9]\"), Pattern.compile(\"bar-[0-9]\")))))\n                    .build();\n            var author = new HostUser.Builder().id(\"17\").build();\n            var pr = new TestPullRequest(new TestPullRequestStore(null, author, null, List.of(), repo, null, null, false), repo);\n            var command = new CommandInvocation(\"1\", author, new TrailerCommand(), \"trailer\", \"set Trailer-1 foo-1, bar-b\", null);\n\n            var reply = new StringWriter();\n            new TrailerCommand().handle(prBot, pr, null, null, command, null, new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"does not match any valid value pattern\"), reply.toString());\n            assertTrue(reply.toString().contains(\"- `foo-[0-9]`\"), reply.toString());\n            assertTrue(reply.toString().contains(\"- `bar-[0-9]`\"), reply.toString());\n\n            command = new CommandInvocation(\"2\", author, new TrailerCommand(), \"trailer\", \"set Trailer-1 foo-1,\", null);\n            reply = new StringWriter();\n            new TrailerCommand().handle(prBot, pr, null, null, command, null, new PrintWriter(reply));\n            assertTrue(reply.toString().contains(\"does not match any valid value pattern\"), reply.toString());\n            assertTrue(reply.toString().contains(\"- `foo-[0-9]`\"), reply.toString());\n            assertTrue(reply.toString().contains(\"- `bar-[0-9]`\"), reply.toString());\n        }\n    }\n\n    @Test\n    public void commandInvalidValue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = (TestHostedRepository) credentials.getHostedRepository(FAKE_REPO);\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(repo)\n                    .trailerConfigs(List.of(\n                            new TrailerCommand.TrailerConfig(\"Trailer-1\", \"1\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.SINGLE, List.of(Pattern.compile(\"foo\")))))\n                    .build();\n            var author = new HostUser.Builder().id(\"17\").build();\n            var pr = new TestPullRequest(new TestPullRequestStore(null, author, null, List.of(), repo, null, null, false), repo);\n            var command = new CommandInvocation(\"1\", author, new TrailerCommand(), \"trailer\", \"set Trailer-1 invalid\", null);\n\n            var reply = new StringWriter();\n            new TrailerCommand().handle(prBot, pr, null, null, command, null, new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"Trailer value `invalid` for trailer `Trailer-1` does not match any valid value pattern:\"), reply.toString());\n            assertTrue(reply.toString().contains(\"- `foo`\"));\n        }\n    }\n\n    @Test\n    public void commandSetAlias(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = (TestHostedRepository) credentials.getHostedRepository(FAKE_REPO);\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(repo)\n                    .trailerConfigs(List.of(\n                            new TrailerCommand.TrailerConfig(\"Trailer-1\", \"1\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.SINGLE, List.of(Pattern.compile(\".*\")))))\n                    .build();\n            var author = new HostUser.Builder().id(\"17\").build();\n            var pr = new TestPullRequest(new TestPullRequestStore(null, author, null, List.of(), repo, null, null, false), repo);\n            var command = new CommandInvocation(\"1\", author, new TrailerCommand(), \"trailer\", \"set 1 foo\", null);\n\n            var reply = new StringWriter();\n            new TrailerCommand().handle(prBot, pr, null, null, command, null, new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"Trailer `Trailer-1` with value `foo` successfully set\"), reply.toString());\n        }\n    }\n\n    @Test\n    public void commandNotAuthor(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = (TestHostedRepository) credentials.getHostedRepository(FAKE_REPO);\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(repo)\n                    .trailerConfigs(List.of(\n                            new TrailerCommand.TrailerConfig(\"Trailer-1\", \"1\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.SINGLE, List.of(Pattern.compile(\".*\")))))\n                    .build();\n            var author = new HostUser.Builder().id(\"17\").username(\"author\").build();\n            var otherUser = new HostUser.Builder().id(\"4711\").username(\"other\").build();\n            var pr = new TestPullRequest(new TestPullRequestStore(null, author, null, List.of(), repo, null, null, false), repo);\n            var command = new CommandInvocation(\"1\", otherUser, new TrailerCommand(), \"trailer\", \"set 1 foo\", null);\n\n            var reply = new StringWriter();\n            new TrailerCommand().handle(prBot, pr, null, null, command, null, new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"Only the author\"), reply.toString());\n        }\n    }\n\n    @Test\n    public void commandHelp(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = (TestHostedRepository) credentials.getHostedRepository(FAKE_REPO);\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(repo)\n                    .trailerConfigs(List.of(\n                            new TrailerCommand.TrailerConfig(\"Trailer-1\", \"1\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.SINGLE, List.of(Pattern.compile(\".*\")))))\n                    .build();\n            var author = new HostUser.Builder().id(\"17\").username(\"author\").build();\n            var pr = new TestPullRequest(new TestPullRequestStore(null, author, null, List.of(), repo, null, null, false), repo);\n            var command = new CommandInvocation(\"1\", author, new TrailerCommand(), \"trailer\", \"\", null);\n\n            var reply = new StringWriter();\n            new TrailerCommand().handle(prBot, pr, null, null, command, null, new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"Syntax\"), reply.toString());\n            assertTrue(reply.toString().contains(\"For this repository, the following custom trailers have been configured:\"), reply.toString());\n            assertTrue(reply.toString().contains(\"Key: Trailer-1\"), reply.toString());\n        }\n    }\n\n    @Test\n    public void commandSetInvalid(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = (TestHostedRepository) credentials.getHostedRepository(FAKE_REPO);\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(repo)\n                    .trailerConfigs(List.of(\n                            new TrailerCommand.TrailerConfig(\"Trailer-1\", \"1\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.SINGLE, List.of(Pattern.compile(\".*\")))))\n                    .build();\n            var author = new HostUser.Builder().id(\"17\").username(\"author\").build();\n            var pr = new TestPullRequest(new TestPullRequestStore(null, author, null, List.of(), repo, null, null, false), repo);\n            var command = new CommandInvocation(\"1\", author, new TrailerCommand(), \"trailer\", \"set foo bar\", null);\n\n            var reply = new StringWriter();\n            new TrailerCommand().handle(prBot, pr, null, null, command, null, new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"is not configured for this repository\"), reply.toString());\n            assertTrue(reply.toString().contains(\"For this repository, the following custom trailers have been configured:\"), reply.toString());\n            assertTrue(reply.toString().contains(\"- Key: Trailer-1\"), reply.toString());\n            assertTrue(reply.toString().contains(\"- Alias: 1\"), reply.toString());\n            assertTrue(reply.toString().contains(\"- Description: Trailer description\"), reply.toString());\n        }\n    }\n\n    @Test\n    public void commandSetNoneConfigured(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = (TestHostedRepository) credentials.getHostedRepository(FAKE_REPO);\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(repo)\n                    .trailerConfigs(List.of())\n                    .build();\n            var author = new HostUser.Builder().id(\"17\").username(\"author\").build();\n            var pr = new TestPullRequest(new TestPullRequestStore(null, author, null, List.of(), repo, null, null, false), repo);\n            var command = new CommandInvocation(\"1\", author, new TrailerCommand(), \"trailer\", \"set foo bar\", null);\n\n            var reply = new StringWriter();\n            new TrailerCommand().handle(prBot, pr, null, null, command, null, new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"There are no custom trailers configured for this repository\"), reply.toString());\n        }\n    }\n\n    @Test\n    public void commandRemoveInvalid(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = (TestHostedRepository) credentials.getHostedRepository(FAKE_REPO);\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(repo)\n                    .trailerConfigs(List.of(\n                            new TrailerCommand.TrailerConfig(\"Trailer-1\", \"1\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.SINGLE, List.of(Pattern.compile(\".*\")))))\n                    .build();\n            var author = new HostUser.Builder().id(\"17\").username(\"author\").build();\n            var pr = new TestPullRequest(new TestPullRequestStore(null, author, null, List.of(), repo, null, null, false), repo);\n            var command = new CommandInvocation(\"1\", author, new TrailerCommand(), \"trailer\", \"remove foo\", null);\n\n            var reply = new StringWriter();\n            new TrailerCommand().handle(prBot, pr, null, null, command, null, new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"is not configured for this repository\"), reply.toString());\n            assertTrue(reply.toString().contains(\"For this repository, the following custom trailers have been configured:\"), reply.toString());\n            assertTrue(reply.toString().contains(\"Key: Trailer-1\"), reply.toString());\n        }\n    }\n\n    @Test\n    public void commandRemoveNotSet(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = (TestHostedRepository) credentials.getHostedRepository(FAKE_REPO);\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(repo)\n                    .trailerConfigs(List.of(\n                            new TrailerCommand.TrailerConfig(\"Trailer-1\", \"1\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.SINGLE, List.of(Pattern.compile(\".*\")))))\n                    .build();\n            var author = new HostUser.Builder().id(\"17\").username(\"author\").build();\n            var pr = new TestPullRequest(new TestPullRequestStore(null, author, null, List.of(), repo, null, null, false), repo);\n            var command = new CommandInvocation(\"1\", author, new TrailerCommand(), \"trailer\", \"remove Trailer-1\", null);\n\n            var reply = new StringWriter();\n            new TrailerCommand().handle(prBot, pr, null, null, command, List.of(), new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"There are no custom trailers set for this pull request\"), reply.toString());\n        }\n    }\n\n    @Test\n    public void commandRemoveOtherSet(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = (TestHostedRepository) credentials.getHostedRepository(FAKE_REPO);\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(repo)\n                    .trailerConfigs(List.of(\n                            new TrailerCommand.TrailerConfig(\"Trailer-1\", \"1\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.SINGLE, List.of(Pattern.compile(\".*\"))),\n                            new TrailerCommand.TrailerConfig(\"Trailer-2\", \"2\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.SINGLE, List.of(Pattern.compile(\".*\")))))\n                    .build();\n            var author = new HostUser.Builder().id(\"17\").username(\"author\").build();\n            var pr = new TestPullRequest(new TestPullRequestStore(null, author, null, List.of(), repo, null, null, false), repo);\n            var command = new CommandInvocation(\"1\", author, new TrailerCommand(), \"trailer\", \"remove Trailer-1\", null);\n\n            var reply = new StringWriter();\n            var comments = List.of(new Comment(\"1\", Trailers.setTrailerMarker(\"Trailer-2\", \"bar\"), repo.forge().currentUser(), null,null));\n            new TrailerCommand().handle(prBot, pr, null, null, command, comments, new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"was not found\"), reply.toString());\n            assertTrue(reply.toString().contains(\"Current custom trailers for this pull request are:\"), reply.toString());\n            assertTrue(reply.toString().contains(\"- Trailer-2: bar\"), reply.toString());\n        }\n    }\n\n    @Test\n    public void commandRemoveNoneConfigured(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = (TestHostedRepository) credentials.getHostedRepository(FAKE_REPO);\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(repo)\n                    .trailerConfigs(List.of())\n                    .build();\n            var author = new HostUser.Builder().id(\"17\").username(\"author\").build();\n            var pr = new TestPullRequest(new TestPullRequestStore(null, author, null, List.of(), repo, null, null, false), repo);\n            var command = new CommandInvocation(\"1\", author, new TrailerCommand(), \"trailer\", \"remove foo\", null);\n\n            var reply = new StringWriter();\n            new TrailerCommand().handle(prBot, pr, null, null, command, null, new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"There are no custom trailers configured for this repository\"), reply.toString());\n        }\n    }\n\n    @Test\n    public void commandRemove(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = (TestHostedRepository) credentials.getHostedRepository(FAKE_REPO);\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(repo)\n                    .trailerConfigs(List.of(\n                            new TrailerCommand.TrailerConfig(\"Trailer-1\", \"1\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.SINGLE, List.of(Pattern.compile(\".*\"))),\n                            new TrailerCommand.TrailerConfig(\"Trailer-2\", \"2\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.SINGLE, List.of(Pattern.compile(\".*\")))))\n                    .build();\n            var author = new HostUser.Builder().id(\"17\").username(\"author\").build();\n            var pr = new TestPullRequest(new TestPullRequestStore(null, author, null, List.of(), repo, null, null, false), repo);\n            var command = new CommandInvocation(\"1\", author, new TrailerCommand(), \"trailer\", \"remove Trailer-1\", null);\n\n            var reply = new StringWriter();\n            var comments = List.of(new Comment(\"1\", Trailers.setTrailerMarker(\"Trailer-1\", \"bar\"), repo.forge().currentUser(), null,null));\n            new TrailerCommand().handle(prBot, pr, null, null, command, comments, new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"Trailer `Trailer-1` successfully removed\"), reply.toString());\n        }\n    }\n\n    @Test\n    public void commandRemoveAlias(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = (TestHostedRepository) credentials.getHostedRepository(FAKE_REPO);\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(repo)\n                    .trailerConfigs(List.of(\n                            new TrailerCommand.TrailerConfig(\"Trailer-1\", \"1\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.SINGLE, List.of(Pattern.compile(\".*\"))),\n                            new TrailerCommand.TrailerConfig(\"Trailer-2\", \"2\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.SINGLE, List.of(Pattern.compile(\".*\")))))\n                    .build();\n            var author = new HostUser.Builder().id(\"17\").username(\"author\").build();\n            var pr = new TestPullRequest(new TestPullRequestStore(null, author, null, List.of(), repo, null, null, false), repo);\n            var command = new CommandInvocation(\"1\", author, new TrailerCommand(), \"trailer\", \"remove 1\", null);\n\n            var reply = new StringWriter();\n            var comments = List.of(new Comment(\"1\", Trailers.setTrailerMarker(\"Trailer-1\", \"bar\"), repo.forge().currentUser(), null,null));\n            new TrailerCommand().handle(prBot, pr, null, null, command, comments, new PrintWriter(reply));\n\n            assertTrue(reply.toString().contains(\"Trailer `Trailer-1` successfully removed\"), reply.toString());\n        }\n    }\n\n    @Test\n    public void runWithBot(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n                var tempFolder = new TemporaryDirectory()) {\n            var bot = credentials.getHostedRepository();\n            var author = credentials.getHostedRepository();\n            var reviewer = credentials.getHostedRepository();\n\n            var censusBuilder = credentials.getCensusBuilder()\n                    .addReviewer(reviewer.forge().currentUser().id())\n                    .addCommitter(author.forge().currentUser().id());\n            var prBot = PullRequestBot.newBuilder()\n                    .repo(bot)\n                    .censusRepo(censusBuilder.build())\n                    .trailerConfigs(List.of(\n                            new TrailerCommand.TrailerConfig(\"Trailer-1\", \"1\", \"Trailer description\",\n                                    TrailerCommand.TrailerType.SINGLE, List.of(Pattern.compile(\".*value\")))))\n                    .build();\n\n            // Populate the projects repository\n            var localRepoFolder = tempFolder.path().resolve(\"localrepo\");\n            var localRepo = CheckableRepository.init(localRepoFolder, author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            assertFalse(CheckableRepository.hasBeenEdited(localRepo));\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Issue an invalid command\n            pr.addComment(\"/trailer hello\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr,\"Syntax\");\n\n            // Issue command as other user\n            var approvalPr = reviewer.pullRequest(pr.id());\n            approvalPr.addComment(\"/trailer set Trailer-1 invalid value\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an error\n            assertLastCommentContains(approvalPr,\"Only the author\");\n\n            // Try setting a value that does not match the values regexp\n            pr.addComment(\"/trailer set Trailer-1 something not valid\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an error\n            assertLastCommentContains(approvalPr,\"does not match any valid value pattern\");\n\n            // Set a valid trailer\n            pr.addComment(\"/trailer set Trailer-1 first value\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"successfully set\");\n\n            // Remove something that isn't there when there is another trailer set\n            pr.addComment(\"/trailer remove Unknown-Trailer\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr,\"is not configured for this repository\");\n\n            // Remove the set trailer\n            pr.addComment(\"/trailer remove Trailer-1\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with a success message\n            assertLastCommentContains(pr,\"successfully removed\");\n\n            // Remove something that isn't there when no trailer has been set\n            pr.addComment(\"/trailer remove Unknown-Trailer\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an error message\n            assertLastCommentContains(pr,\"is not configured for this repository\");\n\n            // Set the trailer again\n            pr.addComment(\"/trailer set Trailer-1 second value\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Overwrite it with a new value\n            pr.addComment(\"/trailer set Trailer-1 third value\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // Approve it as another user\n            approvalPr.addReview(Review.Verdict.APPROVED, \"Approved\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The commit message preview should contain the trailer\n            var creditLine = pr.comments().stream()\n                    .flatMap(comment -> comment.body().lines())\n                    .filter(line -> line.contains(\"third value\"))\n                    .filter(line -> line.contains(\"Trailer-1\"))\n                    .findAny()\n                    .orElseThrow();\n            assertEquals(\"Trailer-1: third value\", creditLine);\n\n            var pushed = pr.comments().stream()\n                    .filter(comment -> comment.body().contains(\"change now passes all *automated*\"))\n                    .count();\n            assertEquals(1, pushed);\n\n            // Change value again\n            pr.addComment(\"/trailer set Trailer-1 4th value\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            creditLine = pr.comments().stream()\n                    .flatMap(comment -> comment.body().lines())\n                    .filter(line -> line.contains(\"4th value\"))\n                    .filter(line -> line.contains(\"Trailer-1:\"))\n                    .findAny()\n                    .orElseThrow();\n            assertEquals(\"Trailer-1: 4th value\", creditLine);\n\n            // Integrate\n            pr.addComment(\"/integrate\");\n            TestBotRunner.runPeriodicItems(prBot);\n\n            // The bot should reply with an ok message\n            assertLastCommentContains(pr,\"Pushed as commit\");\n\n            // The change should now be present on the master branch\n            var pushedFolder = tempFolder.path().resolve(\"pushed\");\n            var pushedRepo = Repository.materialize(pushedFolder, author.authenticatedUrl(), \"master\");\n            assertTrue(CheckableRepository.hasBeenEdited(pushedRepo));\n\n            var headHash = pushedRepo.resolve(\"HEAD\").orElseThrow();\n            Commit headCommit;\n            try (Commits commits = pushedRepo.commits(headHash.hex() + \"^..\" + headHash.hex())) {\n                headCommit = commits.asList().getFirst();\n            }\n\n            // The contributor should be credited\n            creditLine = headCommit.message().stream()\n                    .filter(line -> line.contains(\"4th value\"))\n                    .filter(line -> line.contains(\"Trailer-1:\"))\n                    .findAny()\n                    .orElseThrow();\n            assertEquals(\"Trailer-1: 4th value\", creditLine);\n        }\n    }\n}\n"
  },
  {
    "path": "bots/submit/build.gradle",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.submit'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.bots.submit' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':ci')\n    implementation project(':bot')\n    implementation project(':host')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':census')\n    implementation project(':json')\n    implementation project(':vcs')\n    implementation project(':metrics')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "bots/submit/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nimport org.openjdk.skara.bots.submit.*;\n\nmodule org.openjdk.skara.bots.submit {\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.host;\n    requires org.openjdk.skara.census;\n    requires org.openjdk.skara.json;\n    requires org.openjdk.skara.vcs;\n    requires java.logging;\n\n    provides org.openjdk.skara.bot.BotFactory with SubmitBotFactory;\n    provides org.openjdk.skara.bots.submit.SubmitExecutorFactory with ShellExecutorFactory;\n\n    uses org.openjdk.skara.bots.submit.SubmitExecutorFactory;\n}\n"
  },
  {
    "path": "bots/submit/src/main/java/org/openjdk/skara/bots/submit/CheckUpdater.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.submit;\n\nimport org.openjdk.skara.forge.*;\n\nimport java.time.*;\nimport java.util.logging.Logger;\n\npublic class CheckUpdater implements Runnable {\n    private final PullRequest pr;\n    private final CheckBuilder checkBuilder;\n    private Instant lastUpdate;\n    private Duration maxUpdateRate;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.submit\");\n\n    CheckUpdater(PullRequest pr, CheckBuilder checkBuilder) {\n        this.pr = pr;\n        this.checkBuilder = checkBuilder;\n\n        lastUpdate = Instant.EPOCH;\n        maxUpdateRate = Duration.ofSeconds(10);\n    }\n\n    void setMaxUpdateRate(Duration maxUpdateRate) {\n        this.maxUpdateRate = maxUpdateRate;\n    }\n\n    @Override\n    public void run() {\n        if (Instant.now().isAfter(lastUpdate.plus(maxUpdateRate))) {\n            pr.updateCheck(checkBuilder.build());\n            lastUpdate = Instant.now();\n        } else {\n            log.finest(\"Rate limiting check updates\");\n        }\n    }\n}\n"
  },
  {
    "path": "bots/submit/src/main/java/org/openjdk/skara/bots/submit/ShellExecutor.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.submit;\n\nimport org.openjdk.skara.forge.CheckBuilder;\n\nimport java.io.*;\nimport java.nio.file.Path;\nimport java.time.*;\nimport java.util.*;\nimport java.util.logging.Logger;\n\npublic class ShellExecutor implements SubmitExecutor {\n    private final List<String> cmd;\n    private final String name;\n    private final Duration timeout;\n    private final Map<String, String> environment;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.submit\");\n\n    ShellExecutor(String name, List<String> cmd, Duration timeout, Map<String, String> environment) {\n        this.cmd = cmd;\n        this.name = name;\n        this.timeout = timeout;\n        this.environment = environment;\n    }\n\n    @Override\n    public Duration timeout() {\n        return timeout;\n    }\n\n    @Override\n    public String checkName() {\n        return name;\n    }\n\n    private String outputSummary(List<String> lines) {\n        var lastLines = lines.subList(Math.max(lines.size() - 11, 0), lines.size());\n        return \"Last 10 lines of output (\" + lines.size() + \" total lines):\\n\\n```\\n\" +\n                String.join(\"\\n\", lastLines) + \"\\n```\";\n    }\n\n    private String durationSummary(Instant start) {\n        var executionTime = Duration.between(start, Instant.now());\n        if (executionTime.toSeconds() < 60) {\n            return executionTime.toSeconds() + \" second\" + (executionTime.toSeconds() != 1 ? \"s\" : \"\");\n        } else if (executionTime.toMinutes() < 60) {\n            return executionTime.toMinutes() + \" minute\" + (executionTime.toMinutes() != 1 ? \"s\" : \"\");\n        } else {\n            return executionTime.toHours() + \" hour\" + (executionTime.toHours() != 1 ? \"s\" : \"\") +\n                executionTime.toMinutes() + \" minute\" + (executionTime.toMinutes() % 60 != 1 ? \"s\" : \"\");\n        }\n    }\n\n    @Override\n    public void run(Path prFiles, CheckBuilder checkBuilder, Runnable updateProgress) {\n        var lines = new ArrayList<String>();\n        var start = Instant.now();\n        try {\n            checkBuilder.title(\"Shell command execution starting\");\n            updateProgress.run();\n            var pb = new ProcessBuilder(cmd.toArray(new String[0]))\n                    .redirectErrorStream(true)\n                    .redirectOutput(ProcessBuilder.Redirect.PIPE)\n                    .directory(prFiles.toFile());\n            pb.environment().putAll(environment);\n            var process = pb.start();\n\n            var watchdog = new Thread(() -> {\n                try {\n                    Thread.sleep(timeout);\n                } catch (InterruptedException ignored) {\n                }\n                process.destroyForcibly();\n            });\n            watchdog.start();\n\n            try {\n                var stdout = new BufferedReader(new InputStreamReader(process.getInputStream()));\n                var line = stdout.readLine();\n                while (line != null) {\n                    line = line.replaceAll(\"[^\\\\p{Print}]\", \"\");\n                    log.fine(\"stdout: \" + line);\n                    lines.add(line);\n                    checkBuilder.title(\"Shell command execution in progress for \" + durationSummary(start));\n                    checkBuilder.summary(outputSummary(lines));\n                    updateProgress.run();\n                    line = stdout.readLine();\n                }\n\n                var exitValue = process.waitFor();\n                log.fine(\"exit value: \" + exitValue);\n                if (exitValue == 0) {\n                    checkBuilder.complete(true);\n                    checkBuilder.title(\"Shell command executed successfully in \" + durationSummary(start));\n                    checkBuilder.summary(null);\n                } else {\n                    checkBuilder.complete(false);\n                    checkBuilder.title(\"Shell command execution failed after \" + durationSummary(start) + \" (exit code \" + exitValue + \")\");\n                    if (!lines.isEmpty()) {\n                        checkBuilder.summary(outputSummary(lines));\n                    }\n                }\n            } catch (InterruptedException e) {\n                checkBuilder.complete(false);\n                checkBuilder.title(\"Shell command execution interrupted after \" + durationSummary(start) + \": \" + e.getMessage());\n                if (!lines.isEmpty()) {\n                    checkBuilder.summary(outputSummary(lines));\n                }\n            } finally {\n                watchdog.interrupt();\n            }\n        } catch (IOException e) {\n            checkBuilder.complete(false);\n            checkBuilder.title(\"Failed to execute shell command after \" + durationSummary(start) + \": \" + e.getMessage());\n            if (!lines.isEmpty()) {\n                checkBuilder.summary(outputSummary(lines));\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bots/submit/src/main/java/org/openjdk/skara/bots/submit/ShellExecutorFactory.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.submit;\n\nimport org.openjdk.skara.json.*;\n\nimport java.time.Duration;\nimport java.util.HashMap;\nimport java.util.stream.Collectors;\n\npublic class ShellExecutorFactory implements SubmitExecutorFactory {\n    @Override\n    public String name() {\n        return \"shell\";\n    }\n\n    @Override\n    public SubmitExecutor create(String name, Duration timeout, JSONObject config) {\n        var cmd = config.get(\"cmd\").stream()\n                .map(JSONValue::asString)\n                .collect(Collectors.toList());\n        var checkName = config.get(\"name\").asString();\n\n        var env = new HashMap<String, String>();\n        if (config.contains(\"env\")) {\n            for (var key : config.get(\"env\").fields()) {\n                env.put(key.name(), key.value().asString());\n            }\n        }\n\n        return new ShellExecutor(checkName, cmd, timeout, env);\n    }\n}\n"
  },
  {
    "path": "bots/submit/src/main/java/org/openjdk/skara/bots/submit/SubmitBot.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.submit;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.forge.*;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class SubmitBot implements Bot {\n    private final HostedRepository repository;\n    private final List<SubmitExecutor> executors;\n    private final PullRequestUpdateCache updateCache;\n\n    SubmitBot(HostedRepository repository, List<SubmitExecutor> executors) {\n        this.repository = repository;\n        this.executors = executors;\n        this.updateCache = new PullRequestUpdateCache();\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        return repository.openPullRequests().stream()\n                         .filter(updateCache::needsUpdate)\n                         .flatMap(pr -> executors.stream()\n                                                 .map(executor -> new SubmitBotWorkItem(this, executor, pr)))\n                         .collect(Collectors.toList());\n    }\n\n    HostedRepository repository() {\n        return repository;\n    }\n\n    @Override\n    public String name() {\n        return SubmitBotFactory.NAME;\n    }\n\n    @Override\n    public String toString() {\n        return \"SubmitBot@\" + repository.name();\n    }\n}\n"
  },
  {
    "path": "bots/submit/src/main/java/org/openjdk/skara/bots/submit/SubmitBotFactory.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.submit;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.json.JSONValue;\n\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\n\npublic class SubmitBotFactory implements BotFactory {\n    static final String NAME = \"submit\";\n    @Override\n    public String name() {\n        return NAME;\n    }\n\n    @Override\n    public List<Bot> create(BotConfiguration configuration) {\n        var ret = new ArrayList<Bot>();\n        var specific = configuration.specific();\n\n        var executorFactories = SubmitExecutorFactory.getSubmitExecutorFactories().stream()\n                                                     .collect(Collectors.toMap(SubmitExecutorFactory::name,\n                                                                               Function.identity()));\n        var executorInstances = new HashMap<String, SubmitExecutor>();\n        for (var executorDefinition : specific.get(\"executors\").fields()) {\n            var executorConfig = executorDefinition.value().asObject();\n            var executorType = executorConfig.get(\"type\").asString();\n            var executorTimeout = Duration.parse(executorConfig.get(\"timeout\").asString());\n            if (!executorFactories.containsKey(executorType)) {\n                throw new RuntimeException(\"Unknown executor type: \" + executorType);\n            }\n            var executor = executorFactories.get(executorType).create(executorDefinition.name(),\n                                                                      executorTimeout,\n                                                                      executorConfig.get(\"config\").asObject());\n            executorInstances.put(executorDefinition.name(), executor);\n        }\n\n        for (var repo : specific.get(\"repositories\").fields()) {\n            var repoExecutors = repo.value().stream()\n                                    .map(JSONValue::asString)\n                                    .collect(Collectors.toSet());\n            var repoInstances = executorInstances.entrySet().stream()\n                                                 .filter(entry -> repoExecutors.contains(entry.getKey()))\n                                                 .map(Map.Entry::getValue)\n                                                 .collect(Collectors.toList());\n            var bot = new SubmitBot(configuration.repository(repo.name()), repoInstances);\n            ret.add(bot);\n        }\n\n        return ret;\n    }\n}\n"
  },
  {
    "path": "bots/submit/src/main/java/org/openjdk/skara/bots/submit/SubmitBotWorkItem.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.submit;\n\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.vcs.Repository;\n\nimport java.io.*;\nimport java.nio.file.Path;\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.logging.Logger;\n\npublic class SubmitBotWorkItem implements WorkItem {\n    private final SubmitBot bot;\n    private final SubmitExecutor executor;\n    private final PullRequest pr;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots.submit\");\n\n    SubmitBotWorkItem(SubmitBot bot, SubmitExecutor executor, PullRequest pr) {\n        this.bot = bot;\n        this.executor = executor;\n        this.pr = pr;\n    }\n\n    @Override\n    public String toString() {\n        return \"SubmitWorkItem@\" + bot.repository().name() + \"#\" + pr.id() + \":\" + executor.checkName();\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof SubmitBotWorkItem otherItem)) {\n            return true;\n        }\n        if (!executor.checkName().equals(otherItem.executor.checkName())) {\n            return true;\n        }\n        if (!pr.isSame(otherItem.pr)) {\n            return true;\n        }\n        return false;\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        // Is the check already up to date?\n        var checks = pr.checks(pr.headHash());\n        if (checks.containsKey(executor.checkName())) {\n            var check = checks.get(executor.checkName());\n            if (check.startedAt().isBefore(ZonedDateTime.now().minus(executor.timeout())) && check.status() == CheckStatus.IN_PROGRESS) {\n                log.info(\"Check for hash \" + pr.headHash() + \" is too old - running again\");\n            } else {\n                log.fine(\"Hash \" + pr.headHash() + \" already has a check - skipping\");\n                return List.of();\n            }\n        }\n\n        var prFolder = scratchPath.resolve(\"submit\").resolve(pr.repository().name());\n\n        // Materialize the PR's target ref\n        try {\n            var localRepo = Repository.materialize(prFolder, pr.repository().authenticatedUrl(),\n                                                   \"+\" + pr.targetRef() + \":submit_\" + pr.repository().name());\n            var headHash = localRepo.fetch(pr.repository().authenticatedUrl(), pr.headHash().hex(), false).orElseThrow();\n\n            var checkBuilder = CheckBuilder.create(executor.checkName(), headHash);\n            pr.createCheck(checkBuilder.build());\n\n            var checkUpdater = new CheckUpdater(pr, checkBuilder);\n            executor.run(prFolder, checkBuilder, checkUpdater);\n            pr.updateCheck(checkBuilder.build());\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n\n        return List.of();\n    }\n\n    @Override\n    public String botName() {\n        return SubmitBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return botName();\n    }\n}\n"
  },
  {
    "path": "bots/submit/src/main/java/org/openjdk/skara/bots/submit/SubmitExecutor.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.submit;\n\nimport org.openjdk.skara.forge.CheckBuilder;\n\nimport java.time.Duration;\nimport java.nio.file.Path;\n\npublic interface SubmitExecutor {\n    Duration timeout();\n    String checkName();\n    void run(Path prFiles, CheckBuilder checkBuilder, Runnable updateProgress);\n}\n"
  },
  {
    "path": "bots/submit/src/main/java/org/openjdk/skara/bots/submit/SubmitExecutorFactory.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.submit;\n\nimport org.openjdk.skara.json.JSONObject;\n\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.stream.*;\n\npublic interface SubmitExecutorFactory {\n    String name();\n    SubmitExecutor create(String name, Duration timeout, JSONObject config);\n\n    static List<SubmitExecutorFactory> getSubmitExecutorFactories() {\n        return StreamSupport.stream(ServiceLoader.load(SubmitExecutorFactory.class).spliterator(), false)\n                            .collect(Collectors.toList());\n    }\n}\n"
  },
  {
    "path": "bots/submit/src/test/java/org/openjdk/skara/bots/submit/CheckUpdaterTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.submit;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.test.*;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\nimport java.time.Duration;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass CheckUpdaterTests {\n    @Test\n    void rateLimit(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            var builder = CheckBuilder.create(\"test\", editHash);\n            pr.createCheck(builder.build());\n\n            var updater = new CheckUpdater(pr, builder);\n            updater.setMaxUpdateRate(Duration.ofDays(1));\n            builder.summary(\"In progress\");\n            updater.run();\n\n            // Verify that the check is in progress\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"test\");\n            assertEquals(CheckStatus.IN_PROGRESS, check.status());\n            assertEquals(\"In progress\", check.summary().orElseThrow());\n\n            builder.summary(\"Quick update\");\n            updater.run();\n\n            // Verify that the check still is in progress and has not been updated due to the rate limiter\n            checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            check = checks.get(\"test\");\n            assertEquals(CheckStatus.IN_PROGRESS, check.status());\n            assertEquals(\"In progress\", check.summary().orElseThrow());\n\n            // Now lower the limit\n            updater.setMaxUpdateRate(Duration.ZERO);\n\n            builder.summary(\"Final update\");\n            updater.run();\n\n            // The summary should now have been updated\n            checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            check = checks.get(\"test\");\n            assertEquals(CheckStatus.IN_PROGRESS, check.status());\n            assertEquals(\"Final update\", check.summary().orElseThrow());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/submit/src/test/java/org/openjdk/skara/bots/submit/ShellExecutorTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.submit;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.vcs.Hash;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass ShellExecutorTests {\n    @Test\n    void shellEnvironmentSet() throws IOException {\n        try (var tempFolder = new TemporaryDirectory()) {\n            var executor = new ShellExecutor(\"test\", List.of(\"bash\", \"-c\", \"test $hello\"), Duration.ofDays(1),\n                                             Map.of(\"hello\", \"1\"));\n            var checkBuilder = CheckBuilder.create(\"test\", new Hash(\"abcd\"));\n            executor.run(tempFolder.path(), checkBuilder, () -> {});\n            var result = checkBuilder.build();\n            assertEquals(CheckStatus.SUCCESS, result.status());\n        }\n    }\n\n    @Test\n    void shellEnvironmentUnset() throws IOException {\n        try (var tempFolder = new TemporaryDirectory()) {\n            var executor = new ShellExecutor(\"test\", List.of(\"bash\", \"-c\", \"test $hello\"), Duration.ofDays(1),\n                                             Map.of());\n            var checkBuilder = CheckBuilder.create(\"test\", new Hash(\"abcd\"));\n            executor.run(tempFolder.path(), checkBuilder, () -> {});\n            var result = checkBuilder.build();\n            assertEquals(CheckStatus.FAILURE, result.status());\n        }\n    }\n\n    @Test\n    void unprintable() throws IOException {\n        try (var tempFolder = new TemporaryDirectory()) {\n            var executor = new ShellExecutor(\"test\", List.of(\"echo\", \"Grüße\\tr\"), Duration.ofDays(1),\n                                             Map.of());\n            var checkBuilder = CheckBuilder.create(\"test\", new Hash(\"abcd\"));\n            var updates = new ArrayList<String>();\n            executor.run(tempFolder.path(), checkBuilder, () -> { checkBuilder.build().summary().ifPresent(updates::add); });\n            var result = checkBuilder.build();\n            assertEquals(CheckStatus.SUCCESS, result.status());\n            assertEquals(1, updates.size());\n            assertTrue(updates.get(0).contains(\"Grer\"));\n        }\n    }\n}\n"
  },
  {
    "path": "bots/submit/src/test/java/org/openjdk/skara/bots/submit/SubmitBotFactoryTest.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.submit;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.json.JWCC;\nimport org.openjdk.skara.test.TestBotFactory;\nimport org.openjdk.skara.test.TestHostedRepository;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass SubmitBotFactoryTest {\n    @Test\n    public void testCreate() {\n        String jsonString = \"\"\"\n                {\n                  \"executors\": {\n                    \"executor1\": {\n                      \"type\": \"shell\",\n                      \"timeout\": \"P3D\",\n                      \"config\": {\n                        \"cmd\": [\n                        ],\n                        \"name\": \"name1\",\n                        \"env\": {\n                          \"key1\": \"val1\",\n                          \"key2\": \"val2\"\n                        }\n                      }\n                    }\n                  },\n                  \"repositories\": {\n                    \"repo1\": \"executor1\"\n                  }\n                }\n                \"\"\";\n        var jsonConfig = JWCC.parse(jsonString).asObject();\n\n        var testBotFactory = TestBotFactory.newBuilder()\n                .addHostedRepository(\"repo1\", new TestHostedRepository(\"repo1\"))\n                .build();\n\n        var bots = testBotFactory.createBots(SubmitBotFactory.NAME, jsonConfig);\n        //A submitBot for every configured repository\n        assertEquals(1, bots.size());\n\n        assertEquals(\"SubmitBot@repo1\", bots.get(0).toString());\n    }\n}"
  },
  {
    "path": "bots/submit/src/test/java/org/openjdk/skara/bots/submit/SubmitBotTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.submit;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.test.*;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\nimport java.time.*;\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass SubmitBotTests {\n    @Test\n    void simpleShell(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n\n            var executor = new ShellExecutor(\"test\", List.of(\"echo\", \"hello\"), Duration.ofDays(1), Map.of());\n            var bot = new SubmitBot(author, List.of(executor));\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Verify that the check passed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"test\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n        }\n    }\n\n    @Test\n    void failedShell(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n\n            var executor = new ShellExecutor(\"test\", List.of(\"command_not_found\"), Duration.ofDays(1), Map.of());\n            var bot = new SubmitBot(author, List.of(executor));\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Verify that the check failed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"test\");\n            assertEquals(CheckStatus.FAILURE, check.status());\n        }\n    }\n\n    @Test\n    void skipExisting(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n\n            var executor = new ShellExecutor(\"test\", List.of(\"command_not_found\"), Duration.ofDays(1), Map.of());\n            var bot = new SubmitBot(author, List.of(executor));\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Create a fake check from a while back\n            var checkBuilder = CheckBuilder.create(\"test\", editHash);\n            pr.createCheck(checkBuilder.build());\n\n            checkBuilder.complete(true);\n            pr.updateCheck(checkBuilder.build());\n\n            // The bot should not overwrite the old check\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Verify that the check is still listed as passed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"test\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n        }\n    }\n    @Test\n    void retryAbandoned(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = credentials.getHostedRepository();\n\n            var executor = new ShellExecutor(\"test\", List.of(\"echo\", \"hello\"), Duration.ofDays(1), Map.of());\n            var bot = new SubmitBot(author, List.of(executor));\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Make a change with a corresponding PR\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Create a fake check from a while back\n            var checkBuilder = CheckBuilder.create(\"test\", editHash);\n            pr.createCheck(checkBuilder.build());\n\n            checkBuilder.startedAt(ZonedDateTime.ofInstant(Instant.EPOCH, ZoneId.systemDefault()));\n            pr.updateCheck(checkBuilder.build());\n\n            // The bot should overwrite the old check\n            TestBotRunner.runPeriodicItems(bot);\n\n            // Verify that the check passed\n            var checks = pr.checks(editHash);\n            assertEquals(1, checks.size());\n            var check = checks.get(\"test\");\n            assertEquals(CheckStatus.SUCCESS, check.status());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/synclabel/build.gradle",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.synclabel'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.bots.synclabel' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':bot')\n    implementation project(':ci')\n    implementation project(':vcs')\n    implementation project(':host')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':census')\n    implementation project(':process')\n    implementation project(':json')\n    implementation project(':network')\n    implementation project(':storage')\n    implementation project(':jbs')\n    implementation project(':metrics')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "bots/synclabel/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.bots.synclabel {\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.host;\n    requires org.openjdk.skara.network;\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.process;\n    requires org.openjdk.skara.storage;\n    requires org.openjdk.skara.jbs;\n    requires java.logging;\n\n    provides org.openjdk.skara.bot.BotFactory with org.openjdk.skara.bots.synclabel.SyncLabelBotFactory;\n}\n"
  },
  {
    "path": "bots/synclabel/src/main/java/org/openjdk/skara/bots/synclabel/SyncLabelBot.java",
    "content": "/*\n * Copyright (c) 2021, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.synclabel;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.issuetracker.IssueProject;\n\nimport java.time.*;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\n\npublic class SyncLabelBot implements Bot {\n    private final IssueProject issueProject;\n    private final Pattern inspect;\n    private final Pattern ignore;\n    private final Map<String, ZonedDateTime> issueUpdatedAt = new HashMap<>();\n\n    private ZonedDateTime lastUpdate;\n\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");\n\n    SyncLabelBot(IssueProject issueProject, Pattern inspect, Pattern ignore) {\n        this.issueProject = issueProject;\n        this.inspect = inspect;\n        this.ignore = ignore;\n    }\n\n    IssueProject issueProject() {\n        return issueProject;\n    }\n\n    Pattern inspect() {\n        return inspect;\n    }\n\n    Pattern ignore() {\n        return ignore;\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        ZonedDateTime updatedAfter;\n        if (lastUpdate == null) {\n            updatedAfter = ZonedDateTime.now().minus(Duration.ofHours(2));\n        } else {\n            updatedAfter = lastUpdate.minus(Duration.ofMinutes(30));\n            lastUpdate = ZonedDateTime.now();\n        }\n\n        var issues = issueProject.issues(updatedAfter);\n        var ret = new ArrayList<WorkItem>();\n        for (var issue : issues) {\n            var lastUpdate = issueUpdatedAt.get(issue.id());\n            if (lastUpdate != null) {\n                if (!issue.updatedAt().isAfter(lastUpdate)) {\n                    continue;\n                }\n            }\n            issueUpdatedAt.put(issue.id(), issue.updatedAt());\n            ret.add(new SyncLabelBotFindMainIssueWorkItem(this, issue.id()));\n        }\n        return ret;\n    }\n\n    @Override\n    public String name() {\n        return SyncLabelBotFactory.NAME;\n    }\n\n    @Override\n    public String toString() {\n        return \"SyncLabelBot@\" + issueProject.name();\n    }\n}\n"
  },
  {
    "path": "bots/synclabel/src/main/java/org/openjdk/skara/bots/synclabel/SyncLabelBotFactory.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.synclabel;\n\nimport org.openjdk.skara.bot.*;\n\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\n\npublic class SyncLabelBotFactory implements BotFactory {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");\n\n    static final String NAME = \"synclabel\";\n    @Override\n    public String name() {\n        return NAME;\n    }\n\n    @Override\n    public List<Bot> create(BotConfiguration configuration) {\n        var bots = new ArrayList<Bot>();\n        var specific = configuration.specific();\n        for (var issueproject : specific.get(\"issueprojects\").asArray()) {\n            var project = configuration.issueProject(issueproject.get(\"project\").asString());\n            var inspect = issueproject.contains(\"inspect\") ? Pattern.compile(issueproject.get(\"inspect\").asString()) : Pattern.compile(\".*\");\n            var ignore = issueproject.contains(\"ignore\") ? Pattern.compile(issueproject.get(\"ignore\").asString()) : Pattern.compile(\"\\\\b\\\\B\");\n            bots.add(new SyncLabelBot(project, inspect, ignore));\n        }\n        return bots;\n    }\n}\n"
  },
  {
    "path": "bots/synclabel/src/main/java/org/openjdk/skara/bots/synclabel/SyncLabelBotFindMainIssueWorkItem.java",
    "content": "/*\n * Copyright (c) 2021, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.synclabel;\n\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.jbs.Backports;\n\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.logging.Logger;\n\npublic class SyncLabelBotFindMainIssueWorkItem implements WorkItem {\n    private final String issueId;\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");\n    private final SyncLabelBot bot;\n\n    SyncLabelBotFindMainIssueWorkItem(SyncLabelBot bot, String issueId) {\n        this.bot = bot;\n        this.issueId = issueId;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof SyncLabelBotFindMainIssueWorkItem o)) {\n            return true;\n        }\n        return !o.issueId.equals(issueId);\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        var issue = bot.issueProject().issue(issueId);\n        if (issue.isEmpty()) {\n            log.severe(\"Issue \" + issueId + \" is no longer present!\");\n            return List.of();\n        }\n\n        var primary = Backports.findMainIssue(issue.get());\n        if (primary.isEmpty()) {\n            log.info(\"No main issue found for \" + issue.get().id());\n            return List.of();\n        }\n\n        return List.of(new SyncLabelBotUpdateLabelWorkItem(bot, primary.get().id()));\n    }\n\n    @Override\n    public String toString() {\n        return \"SyncLabelBotFindMainIssueWorkItem@\" + issueId;\n    }\n\n    @Override\n    public String botName() {\n        return SyncLabelBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return \"find-main-issue\";\n    }\n}\n"
  },
  {
    "path": "bots/synclabel/src/main/java/org/openjdk/skara/bots/synclabel/SyncLabelBotUpdateLabelWorkItem.java",
    "content": "/*\n * Copyright (c) 2021, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.synclabel;\n\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.jbs.*;\n\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.*;\n\npublic class SyncLabelBotUpdateLabelWorkItem implements WorkItem {\n    private final SyncLabelBot bot;\n    private final String mainIssueId;\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");\n\n    SyncLabelBotUpdateLabelWorkItem(SyncLabelBot bot, String mainIssueId) {\n        this.bot = bot;\n        this.mainIssueId = mainIssueId;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof SyncLabelBotUpdateLabelWorkItem o)) {\n            return true;\n        }\n        return !o.mainIssueId.equals(mainIssueId);\n    }\n\n    @Override\n    public String toString() {\n        return \"SyncLabelBotUpdateLabelWorkItem@\" + mainIssueId;\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratch) {\n        var issue = bot.issueProject().issue(mainIssueId);\n        if (issue.isEmpty()) {\n            log.severe(\"Issue \" + mainIssueId + \" is no longer present!\");\n            return List.of();\n        }\n\n        var allIssues = Stream.concat(\n                        Stream.of(issue.get())\n                                .filter(Issue::isFixed),\n                        Backports.findBackports(issue.get(), true).stream())\n                .filter(i -> !i.labelNames().contains(\"hgupdate-sync-ignore\"))\n                .filter(i -> Backports.mainFixVersion(i).isPresent())\n                .filter(i -> bot.inspect().matcher(Backports.mainFixVersion(i).get().raw()).matches())\n                .filter(i -> !bot.ignore().matcher(Backports.mainFixVersion(i).get().raw()).matches())\n                .collect(Collectors.toList());\n\n        var needsLabel = Backports.releaseStreamDuplicates(allIssues);\n        for (var i : allIssues) {\n            var version = Backports.mainFixVersion(i);\n            var versionString = version.map(JdkVersion::raw).orElse(\"no fix version\");\n            if (needsLabel.contains(i)) {\n                if (i.labelNames().contains(\"hgupdate-sync\")) {\n                    log.finer(i.id() + \" (\" + versionString + \") - already labeled\");\n                } else {\n                    log.info(i.id() + \" (\" + versionString + \") - needs to be labeled\");\n                    i.addLabel(\"hgupdate-sync\");\n                }\n            } else {\n                if (i.labelNames().contains(\"hgupdate-sync\")) {\n                    log.info(i.id() + \" (\" + versionString + \") - labeled incorrectly!\");\n                    i.removeLabel(\"hgupdate-sync\");\n                } else {\n                    log.finer(i.id() + \" (\" + versionString + \") - not labeled\");\n                }\n            }\n        }\n\n        return List.of();\n    }\n\n    @Override\n    public String botName() {\n        return SyncLabelBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return \"update-label\";\n    }\n}\n"
  },
  {
    "path": "bots/synclabel/src/test/java/org/openjdk/skara/bots/synclabel/SyncLabelBotFactoryTest.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.synclabel;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.json.JWCC;\nimport org.openjdk.skara.test.TestBotFactory;\nimport org.openjdk.skara.test.TestIssueProject;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass SyncLabelBotFactoryTest {\n    @Test\n    public void testCreate() {\n        String jsonString = \"\"\"\n                {\n                  \"issueprojects\": [\n                    {\n                      \"project\": \"test_bugs/TEST\",\n                      \"inspect\": \".*\",\n                      \"ignore\": \"\\\\\\\\b\\\\\\\\B\"\n                    }\n                  ]\n                }\n                \"\"\";\n        var jsonConfig = JWCC.parse(jsonString).asObject();\n\n        var testBotFactory = TestBotFactory.newBuilder()\n                .addIssueProject(\"test_bugs/TEST\", new TestIssueProject(null, \"TEST\"))\n                .build();\n\n        var bots = testBotFactory.createBots(SyncLabelBotFactory.NAME, jsonConfig);\n        // A syncLabelBot for every configured issueProject\n        assertEquals(1, bots.size());\n\n        SyncLabelBot syncLabelBot1 = (SyncLabelBot) bots.get(0);\n        assertEquals(\"SyncLabelBot@TEST\", syncLabelBot1.toString());\n        assertEquals(\".*\", syncLabelBot1.inspect().toString());\n        assertEquals(\"\\\\b\\\\B\", syncLabelBot1.ignore().toString());\n    }\n}"
  },
  {
    "path": "bots/synclabel/src/test/java/org/openjdk/skara/bots/synclabel/SyncLabelBotTests.java",
    "content": "/*\n * Copyright (c) 2021, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.synclabel;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.json.JSONObject;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.openjdk.skara.issuetracker.Issue.State.RESOLVED;\nimport static org.openjdk.skara.issuetracker.jira.JiraProject.RESOLVED_IN_BUILD;\n\npublic class SyncLabelBotTests {\n    private TestBotFactory testBotBuilder(IssueProject issueProject, Path storagePath) {\n        return testBotBuilder(issueProject, storagePath, null, null);\n    }\n\n    private TestBotFactory testBotBuilder(IssueProject issueProject, Path storagePath, String inspect, String ignore) {\n        var cfg = JSON.object().put(\"project\", issueProject.name());\n        if (inspect != null) {\n            cfg.put(\"inspect\", inspect);\n        }\n        if (ignore != null) {\n            cfg.put(\"ignore\", ignore);\n        }\n        return TestBotFactory.newBuilder()\n                             .addIssueProject(issueProject.name(), issueProject)\n                             .storagePath(storagePath)\n                             .addConfiguration(\"issueprojects\", JSON.array()\n                                                                    .add(cfg))\n                             .build();\n    }\n\n    @Test\n    void testAddLabel(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var syncLabelBot = testBotBuilder(issueProject, storageFolder).create(\"synclabel\", JSON.object());\n\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            issue1.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u182\")));\n            issue1.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue1.setState(RESOLVED);\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue1.store().labelNames());\n\n            var issue2 = credentials.createIssue(issueProject, \"Issue 2\");\n            issue2.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u162\")));\n            issue2.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue2.setState(RESOLVED);\n            issue1.addLink(Link.create(issue2, \"backported by\").build());\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(\"hgupdate-sync\"), issue1.store().labelNames());\n\n            var issue3 = credentials.createIssue(issueProject, \"Issue 3\");\n            issue3.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"10\")));\n            issue3.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue3.setState(RESOLVED);\n            issue1.addLink(Link.create(issue3, \"backported by\").build());\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue3.store().labelNames());\n\n            var issue4 = credentials.createIssue(issueProject, \"Issue 4\");\n            issue4.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"11\")));\n            issue4.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue4.setState(RESOLVED);\n            issue1.addLink(Link.create(issue4, \"backported by\").build());\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(\"hgupdate-sync\"), issue1.store().labelNames());\n            assertEquals(List.of(), issue2.store().labelNames());\n            assertEquals(List.of(), issue3.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue4.store().labelNames());\n\n            // Ensure it is stable\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(\"hgupdate-sync\"), issue1.store().labelNames());\n            assertEquals(List.of(), issue2.store().labelNames());\n            assertEquals(List.of(), issue3.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue4.store().labelNames());\n        }\n    }\n\n    @Test\n    void testRemoveLabel(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var syncLabelBot = testBotBuilder(issueProject, storageFolder).create(\"synclabel\", JSON.object());\n\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            issue1.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u182\")));\n            issue1.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue1.setState(RESOLVED);\n            var issue2 = credentials.createIssue(issueProject, \"Issue 2\");\n            issue2.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u162\")));\n            issue2.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue2.setState(RESOLVED);\n            issue1.addLink(Link.create(issue2, \"backported by\").build());\n            var issue3 = credentials.createIssue(issueProject, \"Issue 3\");\n            issue3.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"10\")));\n            issue3.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue3.setState(RESOLVED);\n            issue1.addLink(Link.create(issue3, \"backported by\").build());\n            var issue4 = credentials.createIssue(issueProject, \"Issue 4\");\n            issue4.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"11\")));\n            issue4.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue4.setState(RESOLVED);\n            issue1.addLink(Link.create(issue4, \"backported by\").build());\n\n            // First correct them\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(\"hgupdate-sync\"), issue1.store().labelNames());\n            assertEquals(List.of(), issue2.store().labelNames());\n            assertEquals(List.of(), issue3.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue4.store().labelNames());\n\n            // Intentionally mislabel\n            issue2.addLabel(\"hgupdate-sync\");\n\n            // They should be restored\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(\"hgupdate-sync\"), issue1.store().labelNames());\n            assertEquals(List.of(), issue2.store().labelNames());\n            assertEquals(List.of(), issue3.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue4.store().labelNames());\n        }\n    }\n\n    @Test\n    void testManualIgnore(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var syncLabelBot = testBotBuilder(issueProject, storageFolder).create(\"synclabel\", JSON.object());\n\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            issue1.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u182\")));\n            issue1.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue1.setState(RESOLVED);\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue1.store().labelNames());\n\n            var issue2 = credentials.createIssue(issueProject, \"Issue 2\");\n            issue2.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u162\")));\n            issue2.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue2.setState(RESOLVED);\n            issue1.addLink(Link.create(issue2, \"backported by\").build());\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(\"hgupdate-sync\"), issue1.store().labelNames());\n\n            var issue3 = credentials.createIssue(issueProject, \"Issue 3\");\n            issue3.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"10\")));\n            issue3.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue3.setState(RESOLVED);\n            issue1.addLink(Link.create(issue3, \"backported by\").build());\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue3.store().labelNames());\n\n            var issue4 = credentials.createIssue(issueProject, \"Issue 4\");\n            issue4.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"11\")));\n            issue4.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue4.setState(RESOLVED);\n            issue1.addLink(Link.create(issue4, \"backported by\").build());\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(\"hgupdate-sync\"), issue1.store().labelNames());\n            assertEquals(List.of(), issue2.store().labelNames());\n            assertEquals(List.of(), issue3.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue4.store().labelNames());\n\n            // Now ignore one of them - it should cause another to change\n            issue3.addLabel(\"hgupdate-sync-ignore\");\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(\"hgupdate-sync\"), issue1.store().labelNames());\n            assertEquals(List.of(), issue2.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync-ignore\"), issue3.store().labelNames());\n            assertEquals(List.of(), issue4.store().labelNames());\n\n            // Rearrange it a bit more\n            var issue5 = credentials.createIssue(issueProject, \"Issue 5\");\n            issue5.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u192\")));\n            issue5.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue5.setState(RESOLVED);\n            issue1.addLink(Link.create(issue5, \"backported by\").build());\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(\"hgupdate-sync\"), issue5.store().labelNames());\n\n            // Now ignore another\n            issue2.addLabel(\"hgupdate-sync-ignore\");\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue1.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync-ignore\"), issue2.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync-ignore\"), issue3.store().labelNames());\n            assertEquals(List.of(), issue4.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue5.store().labelNames());\n\n            // Now ignore the main issue as well\n            issue1.addLabel(\"hgupdate-sync-ignore\");\n\n            // This should lead to issue 5 no longer being a sync issue\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(\"hgupdate-sync-ignore\"), issue1.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync-ignore\"), issue2.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync-ignore\"), issue3.store().labelNames());\n            assertEquals(List.of(), issue4.store().labelNames());\n            assertEquals(List.of(), issue5.store().labelNames());\n        }\n    }\n\n    @Test\n    void testIgnore(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var syncLabelBot = testBotBuilder(issueProject, storageFolder).create(\"synclabel\", JSON.object());\n\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            issue1.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u81\")));\n            issue1.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue1.setState(RESOLVED);\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue1.store().labelNames());\n\n            var issue2 = credentials.createIssue(issueProject, \"Issue 2\");\n            issue2.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u261\")));\n            issue2.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue2.setState(RESOLVED);\n            issue1.addLink(Link.create(issue2, \"backported by\").build());\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue1.store().labelNames());\n\n            var issue3 = credentials.createIssue(issueProject, \"Issue 3\");\n            issue3.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u251\")));\n            issue3.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue3.setState(RESOLVED);\n            issue1.addLink(Link.create(issue3, \"backported by\").build());\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(\"hgupdate-sync\"), issue3.store().labelNames());\n\n            var issue4 = credentials.createIssue(issueProject, \"Issue 4\");\n            issue4.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"emb-8u251\")));\n            issue4.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue4.setState(RESOLVED);\n            issue1.addLink(Link.create(issue4, \"backported by\").build());\n\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue1.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue2.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue3.store().labelNames());\n            assertEquals(List.of(), issue4.store().labelNames());\n\n            // Now try it with a configured ignore - issue 3 should lose its label\n            var syncLabelBotWithIgnore = testBotBuilder(issueProject, storageFolder, null, \"8u8\\\\d\").create(\"synclabel\", JSON.object());\n            TestBotRunner.runPeriodicItems(syncLabelBotWithIgnore);\n            assertEquals(List.of(), issue1.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue2.store().labelNames());\n            assertEquals(List.of(), issue3.store().labelNames());\n            assertEquals(List.of(), issue4.store().labelNames());\n        }\n    }\n\n    @Test\n    void testInspect(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var syncLabelBot = testBotBuilder(issueProject, storageFolder).create(\"synclabel\", JSON.object());\n\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            issue1.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u81\")));\n            issue1.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue1.setState(RESOLVED);\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue1.store().labelNames());\n\n            var issue2 = credentials.createIssue(issueProject, \"Issue 2\");\n            issue2.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u261\")));\n            issue2.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue2.setState(RESOLVED);\n            issue1.addLink(Link.create(issue2, \"backported by\").build());\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue1.store().labelNames());\n\n            var issue3 = credentials.createIssue(issueProject, \"Issue 3\");\n            issue3.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u251\")));\n            issue3.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue3.setState(RESOLVED);\n            issue1.addLink(Link.create(issue3, \"backported by\").build());\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(\"hgupdate-sync\"), issue3.store().labelNames());\n\n            var issue4 = credentials.createIssue(issueProject, \"Issue 4\");\n            issue4.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u361\")));\n            issue4.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue4.setState(RESOLVED);\n            issue1.addLink(Link.create(issue4, \"backported by\").build());\n\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue1.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue2.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue3.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue4.store().labelNames());\n\n            // Now try it with a configured include - issue 2 will now lose its label\n            var syncLabelBotWithIgnore = testBotBuilder(issueProject, storageFolder, \"8u\\\\d6\\\\d\", null).create(\"synclabel\", JSON.object());\n            TestBotRunner.runPeriodicItems(syncLabelBotWithIgnore);\n            assertEquals(List.of(), issue1.store().labelNames());\n            assertEquals(List.of(), issue2.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue3.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue4.store().labelNames());\n        }\n    }\n\n    @Test\n    void testAddLabelWithBuild(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var syncLabelBot = testBotBuilder(issueProject, storageFolder).create(\"synclabel\", JSON.object());\n\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            issue1.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"openjfx17\")));\n            issue1.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue1.setState(RESOLVED);\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue1.store().labelNames());\n\n            var issue2 = credentials.createIssue(issueProject, \"Issue 2\");\n            issue2.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u271\")));\n            issue2.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue2.setProperty(RESOLVED_IN_BUILD, JSON.of(\"b33\"));\n            issue2.setState(RESOLVED);\n            issue1.addLink(Link.create(issue2, \"backported by\").build());\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue1.store().labelNames());\n\n            var issue3 = credentials.createIssue(issueProject, \"Issue 3\");\n            issue3.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u291\")));\n            issue3.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue3.setState(RESOLVED);\n            issue1.addLink(Link.create(issue3, \"backported by\").build());\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue3.store().labelNames());\n\n            var issue4 = credentials.createIssue(issueProject, \"Issue 4\");\n            issue4.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"8u301\")));\n            issue4.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue4.setState(RESOLVED);\n            issue1.addLink(Link.create(issue4, \"backported by\").build());\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue1.store().labelNames());\n            assertEquals(List.of(), issue2.store().labelNames());\n            assertEquals(List.of(), issue3.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue4.store().labelNames());\n        }\n    }\n\n    @Test\n    void testMainIssueNotFixed(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var storageFolder = tempFolder.path().resolve(\"storage\");\n            var issueProject = credentials.getIssueProject();\n            var syncLabelBot = testBotBuilder(issueProject, storageFolder).create(\"synclabel\", JSON.object());\n\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            issue1.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"9.0.1\")));\n            issue1.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue1.setState(RESOLVED);\n            issue1.setProperty(\"resolution\", JSON.object().put(\"name\", JSON.of(\"Won't Fix\")));\n\n            var issue2 = credentials.createIssue(issueProject, \"Issue 2\");\n            issue2.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"9.0.2\")));\n            issue2.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue2.setState(RESOLVED);\n            issue1.addLink(Link.create(issue2, \"backported by\").build());\n\n            var issue3 = credentials.createIssue(issueProject, \"Issue 3\");\n            issue3.setProperty(\"fixVersions\", JSON.array().add(JSON.of(\"9.0.3\")));\n            issue3.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue3.setState(RESOLVED);\n            issue1.addLink(Link.create(issue3, \"backported by\").build());\n\n            TestBotRunner.runPeriodicItems(syncLabelBot);\n            assertEquals(List.of(), issue1.store().labelNames());\n            assertEquals(List.of(), issue2.store().labelNames());\n            assertEquals(List.of(\"hgupdate-sync\"), issue3.store().labelNames());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/tester/build.gradle",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.tester'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        requires 'org.openjdk.skara.host'\n        opens 'org.openjdk.skara.bots.tester' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':bot')\n    implementation project(':ci')\n    implementation project(':census')\n    implementation project(':forge')\n    implementation project(':host')\n    implementation project(':issuetracker')\n    implementation project(':json')\n    implementation project(':vcs')\n    implementation project(':jcheck')\n    implementation project(':metrics')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "bots/tester/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.bots.tester {\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.ci;\n    requires org.openjdk.skara.census;\n    requires org.openjdk.skara.jcheck;\n\n    requires java.logging;\n\n    provides org.openjdk.skara.bot.BotFactory with org.openjdk.skara.bots.tester.TestBotFactory;\n}\n"
  },
  {
    "path": "bots/tester/src/main/java/org/openjdk/skara/bots/tester/Stage.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.tester;\n\nenum Stage {\n    NA,\n    ERROR,\n    REQUESTED,\n    PENDING,\n    APPROVED,\n    STARTED,\n    CANCELLED,\n    FINISHED\n}\n"
  },
  {
    "path": "bots/tester/src/main/java/org/openjdk/skara/bots/tester/State.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.tester;\n\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\n\nimport java.util.function.Predicate;\n\nclass State {\n    private final Stage stage;\n    private final Comment requested;\n    private final Comment pending;\n    private final Comment approval;\n    private final Comment started;\n    private final Comment cancelled;\n    private final Comment finished;\n\n    private State(Stage stage, Comment requested,\n                               Comment pending,\n                               Comment approval,\n                               Comment started,\n                               Comment cancelled,\n                               Comment finished) {\n        this.stage = stage;\n        this.requested = requested;\n        this.pending = pending;\n        this.approval = approval;\n        this.started = started;\n        this.cancelled = cancelled;\n        this.finished = finished;\n    }\n\n    Stage stage() {\n        return stage;\n    }\n\n    Comment requested() {\n        return requested;\n    }\n\n    Comment pending() {\n        return pending;\n    }\n\n    Comment approval() {\n        return approval;\n    }\n\n    Comment started() {\n        return started;\n    }\n\n    Comment cancelled() {\n        return cancelled;\n    }\n\n    Comment finished() {\n        return finished;\n    }\n\n    static State from(PullRequest pr, Predicate<HostUser> validate) {\n        Comment requested = null;\n        Comment pending = null;\n        Comment approval = null;\n        Comment started = null;\n        Comment cancelled = null;\n        Comment error = null;\n        Comment finished = null;\n\n        var isApproved = false;\n\n        var host = pr.repository().forge();\n        var comments = pr.comments();\n        var start = -1;\n        for (var i = comments.size() - 1; i >=0; i--) {\n            var comment = comments.get(i);\n            var lines = comment.body().split(\"\\n\");\n            if (lines.length == 1 &&\n                lines[0].startsWith(\"/test\") &&\n                !lines[0].startsWith(\"/test approve\") &&\n                !lines[0].startsWith(\"/test cancel\")) {\n                requested = comment;\n                start = i;\n                break;\n            }\n        }\n\n        if (requested != null) {\n            var applicable = comments.subList(start, comments.size());\n            for (var comment : applicable) {\n                var body = comment.body();\n                var author = comment.author();\n                if (author.equals(host.currentUser())) {\n                    var lines = body.split(\"\\n\");\n                    switch (lines[0]) {\n                        case \"<!-- TEST PENDING -->\":\n                            pending = comment;\n                            break;\n                        case \"<!-- TEST STARTED -->\":\n                            started = comment;\n                            break;\n                        case \"<!-- TEST ERROR -->\":\n                            error = comment;\n                            break;\n                        case \"<!-- TEST FINISHED -->\":\n                            finished = comment;\n                            break;\n                    }\n                } else if (body.equals(\"/test approve\")) {\n                    approval = comment;\n                    if (validate.test(author)) {\n                        isApproved = true;\n                    }\n                } else if (body.equals(\"/test cancel\")) {\n                    if (comment.author().equals(requested.author())) {\n                        cancelled = comment;\n                    }\n                } else if (body.startsWith(\"/test\")) {\n                    if (validate.test(author)) {\n                        isApproved = true;\n                    }\n                }\n            }\n        }\n\n        Stage stage = null;\n        if (error != null) {\n            stage = Stage.ERROR;\n        } else if (cancelled != null) {\n            stage = Stage.CANCELLED;\n        } else if (finished != null) {\n            stage = Stage.FINISHED;\n        } else if (started != null) {\n            stage = Stage.STARTED;\n        } else if (requested != null && isApproved) {\n            stage = Stage.APPROVED;\n        } else if (requested != null && pending != null) {\n            stage = Stage.PENDING;\n        } else if (requested != null) {\n            stage = Stage.REQUESTED;\n        } else {\n            stage = Stage.NA;\n        }\n\n        return new State(stage, requested, pending, approval, started, cancelled, finished);\n    }\n}\n"
  },
  {
    "path": "bots/tester/src/main/java/org/openjdk/skara/bots/tester/TestBot.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.tester;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.ci.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.jcheck.JCheckConfiguration;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.function.Predicate;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\npublic class TestBot implements Bot {\n    static class Observation {\n        Job.State nextToLast;\n        Job.State last;\n\n        Observation(Job.State nextToLast, Job.State last) {\n            this.nextToLast = nextToLast;\n            this.last = last;\n        }\n    }\n\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n    private final ContinuousIntegration ci;\n    private final String approversGroupId;\n    private final Set<String> allowlist;\n    private final List<String> availableJobs;\n    private final List<String> defaultJobs;\n    private final String name;\n    private final Path storage;\n    private final HostedRepository repo;\n    private final PullRequestUpdateCache cache;\n    private final ConcurrentHashMap<String, Observation> states;\n    private final HostedRepository censusRemote;\n    private final Path censusDir;\n    private final boolean checkCommitterStatus;\n\n    TestBot(ContinuousIntegration ci,\n            String approversGroupId,\n            Set<String> allowlist,\n            List<String> availableJobs,\n            List<String> defaultJobs,\n            String name,\n            Path storage,\n            HostedRepository repo,\n            HostedRepository censusRemote,\n            Path censusDir,\n            boolean checkCommitterStatus) {\n        this.ci = ci;\n        this.approversGroupId = approversGroupId;\n        this.allowlist = allowlist;\n        this.availableJobs = availableJobs;\n        this.defaultJobs = defaultJobs;\n        this.name = name;\n        this.storage = storage;\n        this.repo = repo;\n        this.cache = new PullRequestUpdateCache();\n        this.states = new ConcurrentHashMap<>();\n        this.censusRemote = censusRemote;\n        this.censusDir = censusDir;\n        this.checkCommitterStatus = checkCommitterStatus;\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        Predicate<HostUser> isCommitter = null;\n        if (checkCommitterStatus) {\n            try {\n                var censusRepo = Repository.materialize(censusDir, censusRemote.authenticatedUrl(), Branch.defaultFor(VCS.GIT).name());\n                var census = Census.parse(censusDir);\n                var namespace = census.namespace(repo.namespace());\n                var jcheckConf = repo.fileContents(\".jcheck/conf\", Branch.defaultFor(VCS.GIT).name())\n                        .orElseThrow(() -> new RuntimeException(\"Could not find .jcheck/conf on ref \"\n                                + Branch.defaultFor(VCS.GIT).name() + \" in repo \" + repo.name()));\n                var jcheck = JCheckConfiguration.parse(jcheckConf.lines().collect(Collectors.toList()));\n                var project = census.project(jcheck.general().project());\n                isCommitter = u -> {\n                   var contributor = namespace.get(u.id());\n                   if (contributor == null) {\n                       return false;\n                   }\n                   return project.isCommitter(contributor.username(), census.version().format());\n                };\n            } catch (IOException e) {\n                throw new UncheckedIOException(e);\n            }\n        } else {\n            isCommitter = u -> true;\n        }\n\n        var ret = new ArrayList<WorkItem>();\n\n        var host = repo.webUrl().getHost();\n        var repoId = Long.toString(repo.id());\n        for (var pr : repo.openPullRequests()) {\n            var workItem = new TestWorkItem(ci,\n                                            approversGroupId,\n                                            allowlist,\n                                            availableJobs,\n                                            defaultJobs,\n                                            name,\n                                            storage,\n                                            pr,\n                                            isCommitter);\n            if (cache.needsUpdate(pr)) {\n                ret.add(workItem);\n            } else {\n                ret.add(new TestUpdateNeededWorkItem(pr, ci, states, workItem));\n            }\n        }\n\n        return ret;\n    }\n\n    @Override\n    public String name() {\n        return \"test\";\n    }\n\n    @Override\n    public String toString() {\n        return \"TestBot@\" + repo.name();\n    }\n}\n"
  },
  {
    "path": "bots/tester/src/main/java/org/openjdk/skara/bots/tester/TestBotFactory.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.tester;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.json.*;\n\nimport org.openjdk.skara.ci.ContinuousIntegration;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.stream.Collectors;\nimport java.net.URI;\n\npublic class TestBotFactory implements BotFactory {\n    static final String NAME = \"test\";\n    @Override\n    public String name() {\n        return NAME;\n    }\n\n    @Override\n    public List<Bot> create(BotConfiguration configuration) {\n        var storage = configuration.storageFolder();\n        try {\n            Files.createDirectories(storage);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n\n        var ret = new ArrayList<Bot>();\n        var specific = configuration.specific();\n\n        var censusDir = storage.resolve(\"census.git\");\n        try {\n            Files.createDirectories(censusDir);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        var censusRemote = configuration.repository(specific.get(\"census\").asString());\n        var checkCommitterStatus = specific.contains(\"role\") &&\n                                   specific.get(\"role\").asString().toLowerCase().equals(\"committer\");\n\n        var approvers = specific.get(\"approvers\").asString();\n        var allowlist = specific.get(\"allowlist\").stream().map(JSONValue::asString).collect(Collectors.toSet());\n        var availableJobs = specific.get(\"availableJobs\").stream().map(JSONValue::asString).collect(Collectors.toList());\n        var defaultJobs = specific.get(\"defaultJobs\").stream().map(JSONValue::asString).collect(Collectors.toList());\n        var name = specific.get(\"name\").asString();\n        var ci = configuration.continuousIntegration(specific.get(\"ci\").asString());\n        for (var repo : specific.get(\"repositories\").asArray()) {\n            var hostedRepo = configuration.repository(repo.asString());\n            ret.add(new TestBot(ci, approvers, allowlist, availableJobs, defaultJobs, name, storage, hostedRepo, censusRemote, censusDir, checkCommitterStatus));\n        }\n\n        return ret;\n    }\n}\n"
  },
  {
    "path": "bots/tester/src/main/java/org/openjdk/skara/bots/tester/TestUpdateNeededWorkItem.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.tester;\n\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.ci.*;\nimport org.openjdk.skara.forge.PullRequest;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.logging.Level;\nimport java.util.logging.Logger;\n\npublic class TestUpdateNeededWorkItem implements WorkItem {\n    private final PullRequest pr;\n    private final ContinuousIntegration ci;\n    private final ConcurrentHashMap<String, TestBot.Observation> states;\n    private final TestWorkItem actualWorkItem;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n\n    TestUpdateNeededWorkItem(PullRequest pr,  ContinuousIntegration ci, ConcurrentHashMap<String, TestBot.Observation> states,\n                             TestWorkItem actualWorkItem) {\n        this.pr = pr;\n        this.ci = ci;\n        this.states = states;\n        this.actualWorkItem = actualWorkItem;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof TestUpdateNeededWorkItem o)) {\n            return true;\n        }\n        if (!pr.isSame(o.pr)) {\n            return true;\n        }\n        return false;\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        // is there a job running for this PR?\n        var desc = pr.repository().name() + \"#\" + pr.id();\n        List<Job> jobs = List.of();\n        try {\n            log.info(\"Getting test jobs for \" + desc);\n            jobs = ci.jobsFor(pr);\n        } catch (IOException e) {\n            log.log(Level.INFO, \"Could not retrieve test jobs for PR: \" + desc, e);\n        }\n\n        if (!jobs.isEmpty()) {\n            var shouldUpdate = false;\n            for (var job : jobs) {\n                if (!states.containsKey(job.id())) {\n                    shouldUpdate = true;\n                    states.put(job.id(), new TestBot.Observation(job.state(), job.state()));\n                } else {\n                    var observed = states.get(job.id());\n\n                    if (!observed.last.equals(Job.State.COMPLETED) ||\n                            !observed.nextToLast.equals(Job.State.COMPLETED)) {\n                        shouldUpdate = true;\n                    }\n\n                    observed.nextToLast = observed.last;\n                    observed.last = job.state();\n                }\n            }\n            if (shouldUpdate) {\n                return List.of(actualWorkItem);\n            }\n        }\n        return List.of();\n    }\n\n    @Override\n    public String toString() {\n        return \"TestUpdateNeededWorkItem@\" + pr.repository().name() + \"#\" + pr.id();\n    }\n\n    @Override\n    public String botName() {\n        return TestBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return \"updater\";\n    }\n}\n"
  },
  {
    "path": "bots/tester/src/main/java/org/openjdk/skara/bots/tester/TestWorkItem.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.tester;\n\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.ci.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.host.HostUser;\n\nimport java.io.*;\nimport java.net.*;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.*;\nimport java.util.function.Predicate;\n\npublic class TestWorkItem implements WorkItem {\n    private final static String TEST_REQUEST_LABEL = \"test-request\";\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");;\n    private final ContinuousIntegration ci;\n    private final String approversGroupId;\n    private final Set<String> allowlist;\n    private final List<String> availableJobs;\n    private final List<String> defaultJobs;\n    private final String name;\n    private final Path storage;\n    private final HostedRepository repository;\n    private final PullRequest pr;\n    private final Predicate<HostUser> isCommitter;\n\n    TestWorkItem(ContinuousIntegration ci, String approversGroupId, Set<String> allowlist, List<String> availableJobs,\n                 List<String> defaultJobs, String name, Path storage, PullRequest pr, Predicate<HostUser> isCommitter) {\n        this.ci = ci;\n        this.approversGroupId = approversGroupId;\n        this.allowlist = allowlist;\n        this.availableJobs = availableJobs;\n        this.defaultJobs = defaultJobs;\n        this.name = name;\n        this.storage = storage;\n        this.repository = pr.repository();\n        this.pr = pr;\n        this.isCommitter = isCommitter;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof TestWorkItem o)) {\n            return true;\n        }\n        if (!pr.isSame(o.pr)) {\n            return true;\n        }\n        return false;\n    }\n\n\n    private String jobId(State state) {\n        var host = repository.webUrl().getHost();\n        return host + \"-\" +\n               Long.toString(repository.id()) + \"-\"+\n               pr.id() + \"-\" +\n               state.requested().id();\n    }\n\n\n    private String osDisplayName(Build.OperatingSystem os) {\n        switch (os) {\n            case WINDOWS:\n                return \"Windows\";\n            case MACOS:\n                return \"macOS\";\n            case LINUX:\n                return \"Linux\";\n            case SOLARIS:\n                return \"Solaris\";\n            case AIX:\n                return \"AIX\";\n            case FREEBSD:\n                return \"FreeBSD\";\n            case OPENBSD:\n                return \"OpenBSD\";\n            case NETBSD:\n                return \"NetBSD\";\n            case HPUX:\n                return \"HP-UX\";\n            case HAIKU:\n                return \"Haiku\";\n            default:\n                throw new IllegalArgumentException(\"Unknown operating system: \" + os.toString());\n        }\n    }\n\n    private String cpuDisplayName(Build.CPU cpu) {\n        switch (cpu) {\n            case X86:\n                return \"x86\";\n            case X64:\n                return \"x64\";\n            case SPARCV9:\n                return \"SPARC V9\";\n            case AARCH64:\n                return \"AArch64\";\n            case AARCH32:\n                return \"AArch32\";\n            case PPCLE32:\n                return \"PPC LE 32\";\n            case PPCLE64:\n                return \"PPC LE 64\";\n            default:\n                throw new IllegalArgumentException(\"Unknown cpu: \" + cpu.toString());\n        }\n    }\n\n    private String debugLevelDisplayName(Build.DebugLevel level) {\n        switch (level) {\n            case RELEASE:\n                return \"release\";\n            case FASTDEBUG:\n                return \"fastdebug\";\n            case SLOWDEBUG:\n                return \"slowdebug\";\n            default:\n                throw new IllegalArgumentException(\"Unknown debug level: \" + level.toString());\n        }\n    }\n\n    private void appendIdSection(StringBuilder summary, Job job) {\n        summary.append(\"## Id\");\n        summary.append(\"\\n\");\n\n        summary.append(\"`\");\n        summary.append(job.id());\n        summary.append(\"`\");\n        summary.append(\"\\n\");\n    }\n\n    private void appendBuildsSection(StringBuilder summary, Job job) {\n        var perOSandArch = new HashMap<String, List<String>>();\n        for (var build : job.builds()) {\n            var osAndArch = osDisplayName(build.os()) + \" \" + cpuDisplayName(build.cpu());\n            var debugLevel = debugLevelDisplayName(build.debugLevel());\n            if (!perOSandArch.containsKey(osAndArch)) {\n                perOSandArch.put(osAndArch, new ArrayList<>());\n            }\n            perOSandArch.get(osAndArch).add(debugLevel);\n        }\n\n        summary.append(\"## Builds\");\n        summary.append(\"\\n\");\n\n        for (var key : perOSandArch.keySet()) {\n            summary.append(\"- \");\n            summary.append(key);\n            summary.append(\" (\");\n            summary.append(String.join(\",\", perOSandArch.get(key)));\n            summary.append(\")\");\n            summary.append(\"\\n\");\n        }\n    }\n\n    private void appendTestsSection(StringBuilder summary, Job job) {\n        summary.append(\"## Tests\");\n        summary.append(\"\\n\");\n\n        for (var test : job.tests()) {\n            summary.append(\"- \");\n            summary.append(test.name());\n            summary.append(\"\\n\");\n        }\n    }\n\n    private void appendStatusSection(StringBuilder summary, Job job) {\n        var s = job.status();\n        summary.append(\"## Status\");\n        summary.append(\"\\n\");\n\n        var numCompleted = s.numCompleted();\n        summary.append(Integer.toString(numCompleted));\n        summary.append(numCompleted == 1 ? \" job \" : \" jobs \");\n        summary.append(\"completed, \");\n\n        var numRunning = s.numRunning();\n        summary.append(Integer.toString(numRunning));\n        summary.append(numRunning == 1 ? \" job \" : \" jobs \");\n        summary.append(\"running, \");\n\n        var numNotStarted = s.numNotStarted();\n        summary.append(Integer.toString(numNotStarted));\n        summary.append(numNotStarted == 1 ? \" job \" : \" jobs \");\n        summary.append(\"not yet started\");\n        summary.append(\"\\n\");\n    }\n\n    private void appendResultSection(StringBuilder summary, Job job) {\n        var r = job.result();\n        summary.append(\"## Result\");\n        summary.append(\"\\n\");\n\n        var numPassed = r.numPassed();\n        summary.append(Integer.toString(numPassed));\n        summary.append(numPassed == 1 ? \" job \" : \" jobs \");\n        summary.append(\"passed, \");\n\n        var numFailed = r.numFailed();\n        summary.append(Integer.toString(numFailed));\n        summary.append(numFailed == 1 ? \" job \" : \" jobs \");\n        summary.append(\"with failures, \");\n\n        var numSkipped = r.numSkipped();\n        summary.append(Integer.toString(numSkipped));\n        summary.append(numSkipped == 1 ? \" job \" : \" jobs \");\n        summary.append(\"not run\");\n        summary.append(\"\\n\");\n    }\n\n    private String display(Job job) {\n        var sb = new StringBuilder();\n        appendIdSection(sb, job);\n        sb.append(\"\\n\");\n        appendBuildsSection(sb, job);\n        sb.append(\"\\n\");\n        appendTestsSection(sb, job);\n        sb.append(\"\\n\");\n        appendStatusSection(sb, job);\n        sb.append(\"\\n\");\n        if (job.isCompleted()) {\n            appendResultSection(sb, job);\n        }\n        return sb.toString();\n    }\n\n    private boolean validate(HostUser u) {\n        var forge = pr.repository().forge();\n        return isCommitter.test(u) && (forge.isMemberOf(approversGroupId, u) || allowlist.contains(u.id()));\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        var state = State.from(pr, this::validate);\n        var stage = state.stage();\n        if (stage == Stage.NA || stage == Stage.ERROR || stage == Stage.PENDING || stage == Stage.FINISHED) {\n            // nothing to do\n            return List.of();\n        }\n\n        if (stage == Stage.STARTED) {\n            if (state.started() != null) {\n                var lines = state.started().body().split(\"\\n\");\n                var jobId = lines[1].replace(\"<!-- \", \"\").replace(\" -->\", \"\");\n                var hash = lines[2].replace(\"<!-- \", \"\").replace(\" -->\", \"\");\n\n                try {\n                    var job = ci.job(jobId);\n                    var checks = pr.checks(new Hash(hash));\n                    if (checks.containsKey(name)) {\n                        var check = checks.get(name);\n                        if (check.status() == CheckStatus.IN_PROGRESS) {\n                            var builder = CheckBuilder.from(check);\n                            if (job.isCompleted()) {\n                                var success = job.result().numFailed() == 0 &&\n                                              job.result().numSkipped() == 0;\n                                builder = builder.complete(success);\n                                var requestor = state.requested().author().username();\n                                var commentLines = List.of(\n                                        \"<!-- TEST FINISHED -->\",\n                                        \"<!-- \" + jobId + \" -->\",\n                                        \"<!-- \" + hash + \" -->\",\n                                        \"@\" + requestor + \" your test job with id \" + jobId + \" for commits up until \" + hash.substring(0, 8) + \" has finished.\"\n                                );\n                                pr.addComment(String.join(\"\\n\", commentLines));\n                            }\n                            builder = builder.summary(display(job));\n                            pr.updateCheck(builder.build());\n                        }\n                    } else {\n                        log.warning(\"Could not find check for job with \" + jobId + \" for hash \" + hash + \" for PR \" + pr.webUrl());\n                    }\n                } catch (IOException e) {\n                    throw new UncheckedIOException(e);\n                }\n            } else {\n                log.warning(\"No 'started' comment present for PR \" + pr.webUrl());\n            }\n        } else if (stage == stage.CANCELLED) {\n            if (state.started() != null) {\n                var lines = state.started().body().split(\"\\n\");\n                var jobId = lines[1].replace(\"<!-- \", \"\").replace(\" -->\", \"\");\n                var hash = lines[2].replace(\"<!-- \", \"\").replace(\" -->\", \"\");\n\n                try {\n                    ci.cancel(jobId);\n                } catch (IOException e) {\n                    throw new UncheckedIOException(e);\n                }\n                var checks = pr.checks(new Hash(hash));\n                if (checks.containsKey(name)) {\n                    var check = checks.get(name);\n                    if (check.status() != CheckStatus.CANCELLED) {\n                        var builder = CheckBuilder.from(check);\n                        var newCheck = builder.cancel()\n                                              .build();\n                        pr.updateCheck(newCheck);\n                    }\n                } else {\n                    log.warning(\"Could not find check for job with \" + jobId + \" for hash \" + hash + \" for PR \" + pr.webUrl());\n                }\n            }\n            if (pr.labelNames().contains(TEST_REQUEST_LABEL)) {\n                pr.removeLabel(TEST_REQUEST_LABEL);\n            }\n        } else if (stage == Stage.REQUESTED) {\n            var requestedJobs = state.requested().body().substring(\"/test\".length());\n            if (requestedJobs.trim().isEmpty()) {\n                requestedJobs = String.join(\",\", defaultJobs);\n            }\n            var trimmedJobs = Stream.of(requestedJobs.split(\",\")).map(String::trim).collect(Collectors.toList());\n            var nonExistingJobs = trimmedJobs.stream().filter(s -> !availableJobs.contains(s))\n                                                      .collect(Collectors.toList());\n            if (!nonExistingJobs.isEmpty()) {\n                var wording = nonExistingJobs.size() == 1 ? \"group \" : \"groups \";\n                var lines = List.of(\n                   \"<!-- TEST ERROR -->\",\n                   \"@\" + state.requested().author().username() + \" the test \" + wording + String.join(\",\", nonExistingJobs) + \" does not exist\"\n                );\n                pr.addComment(String.join(\"\\n\", lines));\n            } else {\n                var head = pr.headHash();\n                var lines = List.of(\n                        \"<!-- TEST PENDING -->\",\n                        \"<!-- \" + head.hex() + \" -->\",\n                        \"<!-- \" + String.join(\",\", trimmedJobs) + \" -->\",\n                        \"@\" + state.requested().author().username() + \" you need to get approval to run the tests in \" +\n                        String.join(\",\", trimmedJobs) + \" for commits up until \" + head.abbreviate()\n                );\n                pr.addComment(String.join(\"\\n\", lines));\n                pr.addLabel(TEST_REQUEST_LABEL);\n            }\n        } else if (stage == Stage.APPROVED) {\n            Hash head = null;\n            List<String> jobs = null;\n\n            if (pr.labelNames().contains(TEST_REQUEST_LABEL)) {\n                pr.removeLabel(TEST_REQUEST_LABEL);\n            }\n\n            if (state.pending() != null) {\n                var comment = state.pending();\n                var body = comment.body().split(\"\\n\");\n\n                head = new Hash(body[1].replace(\"<!-- \", \"\").replace(\" -->\", \"\"));\n                var requestedJobs = body[2].replace(\"<!-- \", \"\").replace(\" -->\", \"\");\n                jobs = Arrays.asList(requestedJobs.split(\",\"));\n            } else {\n                var comment = state.requested();\n                var body = comment.body().split(\"\\n\");\n\n                head = pr.headHash();\n                var requestedJobs = state.requested().body().substring(\"/test\".length());\n                if (requestedJobs.trim().isEmpty()) {\n                    requestedJobs = String.join(\",\", defaultJobs);\n                }\n                var trimmedJobs = Stream.of(requestedJobs.split(\",\")).map(String::trim).collect(Collectors.toList());\n                var nonExistingJobs = trimmedJobs.stream().filter(s -> !availableJobs.contains(s))\n                                                          .collect(Collectors.toList());\n                if (!nonExistingJobs.isEmpty()) {\n                    var wording = nonExistingJobs.size() == 1 ? \"group \" : \"groups \";\n                    var lines = List.of(\n                       \"<!-- TEST ERROR -->\",\n                       \"@\" + state.requested().author().username() + \" the test \" + wording + String.join(\",\", nonExistingJobs) + \" does not exist\"\n                    );\n                    pr.addComment(String.join(\"\\n\", lines));\n                    return List.of();\n                }\n\n                jobs = trimmedJobs;\n            }\n            var jobId = jobId(state);\n\n            Job job = null;\n            Hash fetchHead = null;\n            try {\n                var sanitizedUrl = URLEncoder.encode(repository.webUrl().toString(), StandardCharsets.UTF_8);\n                var localRepoDir = storage.resolve(\"mach5-bot\")\n                                          .resolve(sanitizedUrl)\n                                          .resolve(pr.id());\n                var host = repository.webUrl().getHost();\n                Repository localRepo = null;\n                if (!Files.exists(localRepoDir)) {\n                    log.info(\"Cloning \" + repository.name());\n                    Files.createDirectories(localRepoDir);\n                    var url = repository.webUrl().toString();\n                    if (!url.endsWith(\".git\")) {\n                        url += \".git\";\n                    }\n                    localRepo = Repository.clone(URI.create(url), localRepoDir);\n                } else {\n                    log.info(\"Found existing scratch directory for \" + repository.name());\n                    localRepo = Repository.get(localRepoDir).orElseThrow(() -> {\n                            return new RuntimeException(\"Repository in \" + localRepoDir + \" has vanished\");\n                    });\n                }\n                fetchHead = localRepo.fetch(repository.authenticatedUrl(), pr.headHash().hex(), false).orElseThrow();\n                localRepo.checkout(fetchHead, true);\n                job = ci.submit(localRepoDir, jobs, jobId);\n            } catch (IOException e) {\n                var lines = List.of(\n                        \"<!-- TEST ERROR -->\",\n                        \"Could not create test job\"\n                );\n                pr.addComment(String.join(\"\\n\", lines));\n\n                throw new UncheckedIOException(e);\n            }\n\n            var check = CheckBuilder.create(name, fetchHead)\n                                    .title(\"Summary\")\n                                    .summary(display(job))\n                                    .metadata(jobId)\n                                    .build();\n            pr.createCheck(check);\n\n            var lines = List.of(\n                    \"<!-- TEST STARTED -->\",\n                    \"<!-- \" + job.id() + \" -->\",\n                    \"<!-- \" + fetchHead.hex() + \" -->\",\n                    \"A test job has been started with id: \" + job.id()\n            );\n            pr.addComment(String.join(\"\\n\", lines));\n        } else {\n            throw new RuntimeException(\"Unexpected state \" + state);\n        }\n        return List.of();\n    }\n\n    @Override\n    public String toString() {\n        return \"TestWorkItem@\" + pr.repository().name() + \"#\" + pr.id();\n    }\n\n    @Override\n    public String botName() {\n        return TestBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return \"command\";\n    }\n}\n"
  },
  {
    "path": "bots/tester/src/test/java/org/openjdk/skara/bots/tester/InMemoryContinuousIntegration.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.tester;\n\nimport org.openjdk.skara.ci.ContinuousIntegration;\nimport org.openjdk.skara.ci.Job;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.forge.PullRequest;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\n\nclass InMemoryContinuousIntegration implements ContinuousIntegration {\n    static class Submission {\n        Path source;\n        List<String> jobs;\n        String id;\n\n        Submission(Path source, List<String> jobs, String id) {\n            this.source = source;\n            this.jobs = jobs;\n            this.id = id;\n        }\n    }\n\n    List<Submission> submissions = new ArrayList<Submission>();\n    List<String> cancelled = new ArrayList<String>();\n    Map<String, InMemoryJob> jobs = new HashMap<>();\n    boolean throwOnSubmit = false;\n    boolean isValid = true;\n    Map<String, HostUser> users = new HashMap<>();\n    HostUser currentUser = null;\n    Map<String, Set<HostUser>> groups = new HashMap<>();\n\n    @Override\n    public boolean isValid() {\n        return isValid;\n    }\n\n    @Override\n    public String hostname() {\n        return \"test.test\";\n    }\n\n    @Override\n    public Optional<HostUser> user(String username) {\n        return Optional.ofNullable(users.get(username));\n    }\n\n    @Override\n    public HostUser currentUser() {\n        return currentUser;\n    }\n\n    @Override\n    public boolean isMemberOf(String groupId, HostUser user) {\n        var group = groups.get(groupId);\n        return group != null && group.contains(user);\n    }\n\n    @Override\n    public Job submit(Path source, List<String> jobs, String id) throws IOException {\n        if (throwOnSubmit) {\n            throw new IOException(\"Something went wrong\");\n        }\n        submissions.add(new Submission(source, jobs, id));\n        return job(id);\n    }\n\n    @Override\n    public Job job(String id) throws IOException {\n        return jobs.get(id);\n    }\n\n    @Override\n    public void cancel(String id) throws IOException {\n        cancelled.add(id);\n    }\n\n    @Override\n    public List<Job> jobsFor(PullRequest pr) throws IOException {\n        return List.of();\n    }\n}\n"
  },
  {
    "path": "bots/tester/src/test/java/org/openjdk/skara/bots/tester/InMemoryHost.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.tester;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.util.*;\n\nclass InMemoryHost implements Forge {\n    HostUser currentUserDetails = HostUser.create(0, \"openjdk\", \"openjdk [bot]\");\n    Map<String, Set<HostUser>> groups;\n\n    @Override\n    public boolean isValid() {\n        return false;\n    }\n\n    @Override\n    public String name() {\n        return \"InMemory\";\n    }\n\n    @Override\n    public String hostname() {\n        return \"in.memory\";\n    }\n\n    @Override\n    public Optional<HostedRepository> repository(String name) {\n        return Optional.empty();\n    }\n\n    @Override\n    public Optional<HostUser> user(String username) {\n        return Optional.empty();\n    }\n\n    @Override\n    public Optional<HostUser> userById(String id) {\n        return Optional.empty();\n    }\n\n    @Override\n    public HostUser currentUser() {\n        return currentUserDetails;\n    }\n\n    @Override\n    public boolean isMemberOf(String groupId, HostUser user) {\n        return groups.get(groupId).contains(user);\n    }\n\n    @Override\n    public Optional<String> search(Hash hash, boolean includeDiffs) {\n        return Optional.empty();\n    }\n\n    @Override\n    public List<HostUser> groupMembers(String group) {\n        return null;\n    }\n\n    @Override\n    public void addGroupMember(String group, HostUser user) {\n    }\n\n    @Override\n    public MemberState groupMemberState(String group, HostUser user) {\n        return null;\n    }\n\n    @Override\n    public Optional<String> defaultPullRequestTemplate() {\n        return Optional.empty();\n    }\n}\n"
  },
  {
    "path": "bots/tester/src/test/java/org/openjdk/skara/bots/tester/InMemoryHostedRepository.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.tester;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Label;\nimport org.openjdk.skara.json.JSONValue;\nimport org.openjdk.skara.vcs.*;\n\nimport java.net.URI;\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport java.util.*;\n\nclass InMemoryHostedRepository implements HostedRepository {\n    Forge host;\n    URI webUrl;\n    URI url;\n    long id;\n\n    @Override\n    public Forge forge() {\n        return host;\n    }\n\n    @Override\n    public PullRequest createPullRequest(HostedRepository target,\n                                         String targetRef,\n                                         String sourceRef,\n                                         String title,\n                                         List<String> body,\n                                         boolean draft) {\n        return null;\n    }\n\n    @Override\n    public PullRequest pullRequest(String id) {\n        return null;\n    }\n\n    @Override\n    public List<PullRequest> pullRequests() {\n        return null;\n    }\n\n    @Override\n    public List<PullRequest> openPullRequests() {\n        return null;\n    }\n\n    @Override\n    public List<PullRequest> pullRequestsAfter(ZonedDateTime updatedAfter) {\n        return null;\n    }\n\n    @Override\n    public List<PullRequest> openPullRequestsAfter(ZonedDateTime updatedAfter) {\n        return null;\n    }\n\n    @Override\n    public Optional<PullRequest> parsePullRequestUrl(String url) {\n        return null;\n    }\n\n    @Override\n    public String name() {\n        return null;\n    }\n\n    @Override\n    public String group() {\n        return null;\n    }\n\n    @Override\n    public Optional<HostedRepository> parent() {\n        return null;\n    }\n\n    @Override\n    public URI authenticatedUrl() {\n        return url;\n    }\n\n    @Override\n    public URI webUrl() {\n        return webUrl;\n    }\n\n    @Override\n    public URI nonTransformedWebUrl() {\n        return webUrl();\n    }\n\n    @Override\n    public URI webUrl(Hash hash) {\n        return null;\n    }\n\n    @Override\n    public URI webUrl(String baseRef, String headRef) {\n        return null;\n    }\n\n    @Override\n    public URI diffUrl(String prId) {\n        return webUrl();\n    }\n\n    @Override\n    public VCS repositoryType() {\n        return null;\n    }\n\n    @Override\n    public Optional<String> fileContents(String filename, String ref) {\n        return Optional.empty();\n    }\n\n    @Override\n    public void writeFileContents(String filename, String content, Branch branch, String message, String authorName, String authorEmail, boolean createNewFile) {\n    }\n\n    @Override\n    public String namespace() {\n        return null;\n    }\n\n    @Override\n    public Optional<WebHook> parseWebHook(JSONValue body) {\n        return null;\n    }\n\n    @Override\n    public HostedRepository fork() {\n        return null;\n    }\n\n    @Override\n    public long id() {\n        return id;\n    }\n\n    @Override\n    public Optional<Hash> branchHash(String ref) {\n        return Optional.empty();\n    }\n\n    @Override\n    public List<PullRequest> findPullRequestsWithComment(String author, String body) {\n        return null;\n    }\n\n    @Override\n    public List<HostedBranch> branches() {\n        return List.of();\n    }\n\n    @Override\n    public String defaultBranchName() {\n        return null;\n    }\n\n    @Override\n    public void protectBranchPattern(String ref) {\n    }\n\n    @Override\n    public void unprotectBranchPattern(String ref) {\n    }\n\n    @Override\n    public void deleteBranch(String ref) {\n    }\n\n    @Override\n    public List<CommitComment> commitComments(Hash commit) {\n        return List.of();\n    }\n\n    @Override\n    public CommitComment addCommitComment(Hash commit, String body) {\n        return null;\n    }\n\n    @Override\n    public void updateCommitComment(String id, String body) {\n    }\n\n    @Override\n    public Optional<HostedCommit> commit(Hash commit, boolean includeDiffs) {\n        return Optional.empty();\n    }\n\n    @Override\n    public List<Check> allChecks(Hash hash) {\n        return List.of();\n    }\n\n    @Override\n    public WorkflowStatus workflowStatus() {\n        return WorkflowStatus.DISABLED;\n    }\n\n    @Override\n    public List<CommitComment> recentCommitComments(ReadOnlyRepository unused, Set<Integer> excludeAuthors,\n            List<Branch> branches, ZonedDateTime updatedAfter) {\n        return List.of();\n    }\n\n    @Override\n    public URI createPullRequestUrl(HostedRepository target, String sourceRef, String targetRef) {\n        return null;\n    }\n\n    @Override\n    public URI webUrl(Branch branch) {\n        return null;\n    }\n\n    @Override\n    public URI webUrl(Tag tag) {\n        return null;\n    }\n\n    @Override\n    public URI url() {\n        return url;\n    }\n\n    @Override\n    public List<Collaborator> collaborators() {\n        return List.of();\n    }\n\n    @Override\n    public void addCollaborator(HostUser user, boolean canPush) {\n    }\n\n    @Override\n    public void removeCollaborator(HostUser user) {\n    }\n\n    @Override\n    public boolean canPush(HostUser user) {\n        return false;\n    }\n\n    @Override\n    public void restrictPushAccess(Branch branch, HostUser user) {\n    }\n\n    @Override\n    public List<Label> labels() {\n        return List.of();\n    }\n\n    @Override\n    public void addLabel(Label label) {\n    }\n\n    @Override\n    public void updateLabel(Label label) {\n    }\n\n    @Override\n    public void deleteLabel(Label label) {\n    }\n\n    @Override\n    public int deleteDeployKeys(Duration age) {\n        return 0;\n    }\n\n    @Override\n    public boolean canCreatePullRequest(HostUser user) {\n        return false;\n    }\n\n    @Override\n    public List<PullRequest> openPullRequestsWithTargetRef(String targetRef) {\n        return null;\n    }\n\n    @Override\n    public List<String> deployKeyTitles(Duration age) {\n        return List.of();\n    }\n}\n"
  },
  {
    "path": "bots/tester/src/test/java/org/openjdk/skara/bots/tester/InMemoryJob.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.tester;\n\nimport org.openjdk.skara.ci.*;\n\nimport java.util.List;\nimport java.util.ArrayList;\n\nclass InMemoryJob implements Job {\n    String id = \"\";\n    List<Build> builds = new ArrayList<>();\n    List<Test> tests = new ArrayList<>();\n    Job.Status status;\n    Job.Result result;\n    Job.State state;\n\n    @Override\n    public String id() {\n        return id;\n    }\n\n    @Override\n    public List<Build> builds() {\n        return builds;\n    }\n\n    @Override\n    public List<Test> tests() {\n        return tests;\n    }\n\n    @Override\n    public Job.Status status() {\n        return status;\n    }\n\n    @Override\n    public Job.Result result() {\n        return result;\n    }\n\n    @Override\n    public Job.State state() {\n        return state;\n    }\n}\n"
  },
  {
    "path": "bots/tester/src/test/java/org/openjdk/skara/bots/tester/InMemoryPullRequest.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.tester;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.JSONValue;\nimport org.openjdk.skara.vcs.*;\n\nimport java.util.*;\nimport java.util.stream.Collectors;\nimport java.time.*;\nimport java.net.*;\n\nclass InMemoryPullRequest implements PullRequest {\n    List<Comment> comments = new ArrayList<Comment>();\n    List<Review> reviews = new ArrayList<Review>();\n    HostUser author;\n    HostedRepository repository;\n    Hash headHash;\n    String id;\n    Map<String, Map<String, Check>> checks = new HashMap<>();\n    Set<String> labels = new TreeSet<>();\n\n    @Override\n    public HostedRepository repository() {\n        return repository;\n    }\n\n    @Override\n    public String id() {\n        return id;\n    }\n\n    @Override\n    public HostUser author() {\n        return author;\n    }\n\n    @Override\n    public List<Review> reviews() {\n        return reviews;\n    }\n\n    @Override\n    public void addReview(Review.Verdict verdict, String body) {\n    }\n\n    @Override\n    public void updateReview(String id, String body) {\n    }\n\n    @Override\n    public ReviewComment addReviewComment(Hash base, Hash hash, String path, int line, String body) {\n        return null;\n    }\n\n    @Override\n    public ReviewComment addReviewCommentReply(ReviewComment parent, String body) {\n        return null;\n    }\n\n    @Override\n    public List<ReviewComment> reviewComments() {\n        return null;\n    }\n\n    @Override\n    public Hash headHash() {\n        return headHash;\n    }\n\n    @Override\n    public String fetchRef() {\n        return null;\n    }\n\n    @Override\n    public String sourceRef() {\n        return null;\n    }\n\n    @Override\n    public Optional<HostedRepository> sourceRepository() {\n        return Optional.empty();\n    }\n\n    @Override\n    public String targetRef() {\n        return null;\n    }\n\n    @Override\n    public String title() {\n        return null;\n    }\n\n    @Override\n    public String body() {\n        return null;\n    }\n\n    @Override\n    public void setBody(String body) {\n    }\n\n    @Override\n    public List<Comment> comments() {\n        return comments;\n    }\n    void setComments(List<Comment> comments) {\n        this.comments = comments;\n    }\n\n    @Override\n    public Comment addComment(String body) {\n        var user = repository().forge().currentUser();\n        var now = ZonedDateTime.now();\n        var size = comments.size();\n        var lastId = size > 0 ? comments.get(size - 1).id() : null;\n        var comment = new Comment(String.valueOf(lastId != null ? Integer.parseInt(lastId) + 1 : 0), body, user, now, now);\n        comments.add(comment);\n        return comment;\n    }\n\n    @Override\n    public void removeComment(Comment comment) {\n        comments.remove(comment);\n    }\n\n    @Override\n    public Comment updateComment(String id, String body) {\n        var old = comments.stream()\n                .filter(comment -> comment.id().equals(id)).findAny().get();\n        var index = comments().indexOf(old);\n\n        var now = ZonedDateTime.now();\n        var newComment = new Comment(id, body, old.author(), old.createdAt(), now);\n        comments.set(index, newComment);\n        return newComment;\n    }\n\n    @Override\n    public ZonedDateTime createdAt() {\n        return null;\n    }\n\n    @Override\n    public ZonedDateTime updatedAt() {\n        return null;\n    }\n\n    @Override\n    public State state() {\n        return null;\n    }\n\n    @Override\n    public Map<String, Check> checks(Hash hash) {\n        return checks.get(hash.hex());\n    }\n\n    @Override\n    public void createCheck(Check check) {\n        if (!checks.containsKey(check.hash().hex())) {\n            checks.put(check.hash().hex(), new HashMap<>());\n        }\n        checks.get(check.hash().hex()).put(check.name(), check);\n    }\n\n    @Override\n    public void updateCheck(Check check) {\n        if (checks.containsKey(check.hash().hex())) {\n            checks.get(check.hash().hex()).put(check.name(), check);\n        }\n    }\n\n    @Override\n    public URI changeUrl() {\n        return null;\n    }\n\n    @Override\n    public URI changeUrl(Hash base) {\n        return null;\n    }\n\n    @Override\n    public URI commentUrl(Comment comment) {\n        return null;\n    }\n\n    @Override\n    public URI reviewCommentUrl(ReviewComment reviewComment) {\n        return null;\n    }\n\n    @Override\n    public URI reviewUrl(Review review) {\n        return null;\n    }\n\n    @Override\n    public boolean isDraft() {\n        return false;\n    }\n\n    @Override\n    public void setState(State state) {\n    }\n\n    @Override\n    public void addLabel(String label) {\n        labels.add(label);\n    }\n\n    @Override\n    public void removeLabel(String label) {\n        labels.remove(label);\n    }\n\n    @Override\n    public void setLabels(List<String> labels) {\n        this.labels = new HashSet<>(labels);\n    }\n\n    @Override\n    public List<Label> labels() {\n        return labels.stream().map(s -> new Label(s)).collect(Collectors.toList());\n    }\n\n    @Override\n    public URI webUrl() {\n        return null;\n    }\n\n    @Override\n    public List<HostUser> assignees() {\n        return null;\n    }\n\n    @Override\n    public void setAssignees(List<HostUser> assignees) {\n    }\n\n    @Override\n    public void setTitle(String title) {\n    }\n\n    @Override\n    public IssueProject project() {\n        return null;\n    }\n\n    @Override\n    public void makeNotDraft() {\n\n    }\n\n    @Override\n    public Optional<ZonedDateTime> lastMarkedAsDraftTime() {\n        return Optional.empty();\n    }\n\n    @Override\n    public URI diffUrl() {\n        return null;\n    }\n\n    @Override\n    public Optional<ZonedDateTime> labelAddedAt(String label) {\n        return null;\n    }\n\n    @Override\n    public void setTargetRef(String targetRef) {\n\n    }\n\n    @Override\n    public URI headUrl() {\n        return null;\n    }\n\n    @Override\n    public Diff diff() {\n        return null;\n    }\n\n    @Override\n    public Optional<HostUser> closedBy() {\n        return Optional.empty();\n    }\n\n    @Override\n    public URI filesUrl(Hash hash) {\n        return null;\n    }\n\n    @Override\n    public Optional<ZonedDateTime> lastForcePushTime() {\n        return Optional.empty();\n    }\n\n    @Override\n    public Optional<Hash> findIntegratedCommitHash() {\n        return Optional.empty();\n    }\n\n    @Override\n    public Object snapshot() {\n        return this;\n    }\n\n    @Override\n    public List<ReferenceChange> targetRefChanges() {\n        return List.of();\n    }\n\n    @Override\n    public ZonedDateTime lastTouchedTime() {\n        return null;\n    }\n}\n"
  },
  {
    "path": "bots/tester/src/test/java/org/openjdk/skara/bots/tester/StateTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.tester;\n\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.host.HostUser;\n\nimport java.util.*;\nimport java.time.ZonedDateTime;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass StateTests {\n    @Test\n    void noCommentsShouldEqualNA() {\n        var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n        var host = new InMemoryHost();\n        host.currentUserDetails = bot;\n\n        var repo = new InMemoryHostedRepository();\n        repo.host = host;\n\n        var pr = new InMemoryPullRequest();\n        pr.repository = repo;\n\n        var duke = HostUser.create(0, \"duke\", \"Duke\");\n        pr.author = duke;\n        pr.comments = List.of();\n\n        var state = State.from(pr, u -> host.isMemberOf(\"0\", u));\n        assertEquals(Stage.NA, state.stage());\n        assertEquals(null, state.requested());\n        assertEquals(null, state.pending());\n        assertEquals(null, state.started());\n    }\n\n    @Test\n    void testCommentFromNotApprovedUserShouldEqualRequested() {\n        var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n\n        var host = new InMemoryHost();\n        host.currentUserDetails = bot;\n\n        var repo = new InMemoryHostedRepository();\n        repo.host = host;\n\n        var pr = new InMemoryPullRequest();\n        pr.repository = repo;\n\n        var duke = HostUser.create(0, \"duke\", \"Duke\");\n        pr.author = duke;\n\n        var now = ZonedDateTime.now();\n        var comment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n        pr.comments = List.of(comment);\n\n        var approvers = \"0\";\n        host.groups = Map.of(approvers, Set.of());\n\n        var state = State.from(pr, u -> host.isMemberOf(approvers, u));\n        assertEquals(Stage.REQUESTED, state.stage());\n        assertEquals(comment, state.requested());\n        assertEquals(null, state.pending());\n        assertEquals(null, state.started());\n    }\n\n    @Test\n    void testCommentFromApprovedUserShouldEqualApproved() {\n        var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n\n        var host = new InMemoryHost();\n        host.currentUserDetails = bot;\n\n        var repo = new InMemoryHostedRepository();\n        repo.host = host;\n\n        var pr = new InMemoryPullRequest();\n        pr.repository = repo;\n\n        var duke = HostUser.create(0, \"duke\", \"Duke\");\n        pr.author = duke;\n\n        var now = ZonedDateTime.now();\n        var comment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n        pr.comments = List.of(comment);\n\n        var approvers = \"0\";\n        host.groups = Map.of(approvers, Set.of(duke));\n\n        var state = State.from(pr, u -> host.isMemberOf(approvers, u));\n        assertEquals(Stage.APPROVED, state.stage());\n        assertEquals(comment, state.requested());\n        assertEquals(null, state.pending());\n        assertEquals(null, state.started());\n    }\n\n    @Test\n    void testApprovalNeededCommentShouldResultInPending() {\n        var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n\n        var host = new InMemoryHost();\n        host.currentUserDetails = bot;\n\n        var repo = new InMemoryHostedRepository();\n        repo.host = host;\n\n        var pr = new InMemoryPullRequest();\n        pr.repository = repo;\n\n        var duke = HostUser.create(0, \"duke\", \"Duke\");\n        pr.author = duke;\n\n        var now = ZonedDateTime.now();\n        var testComment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n\n        var pendingBody = List.of(\n            \"<!-- TEST PENDING -->\",\n            \"<!-- tier1 -->\",\n            \"@duke you need to get approval to run these tests\"\n        );\n        var pendingComment = new Comment(\"0\", String.join(\"\\n\", pendingBody), bot, now, now);\n        pr.comments = List.of(testComment, pendingComment);\n        host.groups = Map.of(\"0\", Set.of());\n\n        var state = State.from(pr, u -> host.isMemberOf(\"0\", u));\n        assertEquals(Stage.PENDING, state.stage());\n        assertEquals(testComment, state.requested());\n        assertEquals(pendingComment, state.pending());\n        assertEquals(null, state.started());\n    }\n\n    @Test\n    void testStartedCommentShouldResultInRunning() {\n        var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n\n        var host = new InMemoryHost();\n        host.currentUserDetails = bot;\n\n        var repo = new InMemoryHostedRepository();\n        repo.host = host;\n\n        var pr = new InMemoryPullRequest();\n        pr.repository = repo;\n\n        var duke = HostUser.create(0, \"duke\", \"Duke\");\n        pr.author = duke;\n\n        var now = ZonedDateTime.now();\n        var testComment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n\n        var pendingBody = List.of(\n            \"<!-- TEST PENDING -->\",\n            \"<!-- tier1 -->\",\n            \"@duke you need to get approval to run these tests\"\n        );\n        var pendingComment = new Comment(\"1\", String.join(\"\\n\", pendingBody), bot, now, now);\n\n        var member = HostUser.create(2, \"foo\", \"Foo Bar\");\n        var approveComment = new Comment(\"2\", \"/test approve\", member, now, now);\n\n        var startedBody = List.of(\n            \"<!-- TEST STARTED -->\",\n            \"<!-- 0 -->\",\n            \"A test job has been started with id 0\"\n        );\n        var startedComment = new Comment(\"3\", String.join(\"\\n\", startedBody), bot, now, now);\n\n        pr.comments = List.of(testComment, pendingComment, approveComment, startedComment);\n\n        var approvers = \"0\";\n        host.groups = Map.of(approvers, Set.of(member));\n\n        var state = State.from(pr, u -> host.isMemberOf(approvers, u));\n        assertEquals(Stage.STARTED, state.stage());\n        assertEquals(testComment, state.requested());\n        assertEquals(pendingComment, state.pending());\n        assertEquals(startedComment, state.started());\n    }\n\n    @Test\n    void cancelCommentFromAuthorShouldEqualCancelled() {\n        var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n\n        var host = new InMemoryHost();\n        host.currentUserDetails = bot;\n\n        var repo = new InMemoryHostedRepository();\n        repo.host = host;\n\n        var pr = new InMemoryPullRequest();\n        pr.repository = repo;\n\n        var duke = HostUser.create(0, \"duke\", \"Duke\");\n        pr.author = duke;\n\n        var now = ZonedDateTime.now();\n        var testComment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n        var cancelComment = new Comment(\"1\", \"/test cancel\", duke, now, now);\n        pr.comments = List.of(testComment, cancelComment);\n\n        var approvers = \"0\";\n        host.groups = Map.of(approvers, Set.of());\n\n        var state = State.from(pr, u -> host.isMemberOf(approvers, u));\n        assertEquals(Stage.CANCELLED, state.stage());\n        assertEquals(testComment, state.requested());\n        assertEquals(cancelComment, state.cancelled());\n        assertEquals(null, state.pending());\n        assertEquals(null, state.started());\n    }\n\n    @Test\n    void cancelCommentFromAnotherUserShouldHaveNoEffect() {\n        var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n\n        var host = new InMemoryHost();\n        host.currentUserDetails = bot;\n\n        var repo = new InMemoryHostedRepository();\n        repo.host = host;\n\n        var pr = new InMemoryPullRequest();\n        pr.repository = repo;\n\n        var duke = HostUser.create(0, \"duke\", \"Duke\");\n        pr.author = duke;\n\n        var user = HostUser.create(0, \"foo\", \"Foo Bar\");\n\n        var now = ZonedDateTime.now();\n        var testComment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n        var cancelComment = new Comment(\"1\", \"/test cancel\", user, now, now);\n        pr.comments = List.of(testComment, cancelComment);\n\n        var approvers = \"0\";\n        host.groups = Map.of(approvers, Set.of());\n\n        var state = State.from(pr, u -> host.isMemberOf(approvers, u));\n        assertEquals(Stage.REQUESTED, state.stage());\n        assertEquals(testComment, state.requested());\n        assertEquals(null, state.cancelled());\n        assertEquals(null, state.pending());\n        assertEquals(null, state.started());\n    }\n\n    @Test\n    void multipleTestCommentsShouldOnlyCareAboutLast() {\n        var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n\n        var host = new InMemoryHost();\n        host.currentUserDetails = bot;\n\n        var repo = new InMemoryHostedRepository();\n        repo.host = host;\n\n        var pr = new InMemoryPullRequest();\n        pr.repository = repo;\n\n        var duke = HostUser.create(0, \"duke\", \"Duke\");\n        pr.author = duke;\n\n        var now = ZonedDateTime.now();\n        var test1Comment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n        var test2Comment = new Comment(\"1\", \"/test tier1,tier2\", duke, now, now);\n        var test3Comment = new Comment(\"2\", \"/test tier1,tier2,tier3\", duke, now, now);\n        pr.comments = List.of(test1Comment, test2Comment, test3Comment);\n\n        var approvers = \"0\";\n        host.groups = Map.of(approvers, Set.of());\n\n        var state = State.from(pr, u -> host.isMemberOf(approvers, u));\n        assertEquals(Stage.REQUESTED, state.stage());\n        assertEquals(test3Comment, state.requested());\n        assertEquals(null, state.cancelled());\n        assertEquals(null, state.pending());\n        assertEquals(null, state.started());\n    }\n\n    @Test\n    void errorAfterRequestedShouldBeError() {\n        var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n\n        var host = new InMemoryHost();\n        host.currentUserDetails = bot;\n\n        var repo = new InMemoryHostedRepository();\n        repo.host = host;\n\n        var pr = new InMemoryPullRequest();\n        pr.repository = repo;\n\n        var duke = HostUser.create(0, \"duke\", \"Duke\");\n        pr.author = duke;\n\n        var now = ZonedDateTime.now();\n        var testComment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n\n        var lines = List.of(\n            \"<!-- TEST ERROR -->\",\n            \"The test tier1 does not exist\"\n        );\n        var errorComment = new Comment(\"2\", String.join(\"\\n\", lines), bot, now, now);\n        pr.comments = List.of(testComment, errorComment);\n\n        var approvers = \"0\";\n        host.groups = Map.of(approvers, Set.of());\n\n        var state = State.from(pr, u -> host.isMemberOf(approvers, u));\n        assertEquals(Stage.ERROR, state.stage());\n        assertEquals(testComment, state.requested());\n        assertEquals(null, state.pending());\n        assertEquals(null, state.started());\n    }\n\n    @Test\n    void testFinishedCommentShouldResultInFinished() {\n        var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n\n        var host = new InMemoryHost();\n        host.currentUserDetails = bot;\n\n        var repo = new InMemoryHostedRepository();\n        repo.host = host;\n\n        var pr = new InMemoryPullRequest();\n        pr.repository = repo;\n\n        var duke = HostUser.create(0, \"duke\", \"Duke\");\n        pr.author = duke;\n\n        var now = ZonedDateTime.now();\n        var testComment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n\n        var pendingBody = List.of(\n            \"<!-- TEST PENDING -->\",\n            \"<!-- tier1 -->\",\n            \"@duke you need to get approval to run these tests\"\n        );\n        var pendingComment = new Comment(\"1\", String.join(\"\\n\", pendingBody), bot, now, now);\n\n        var member = HostUser.create(2, \"foo\", \"Foo Bar\");\n        var approveComment = new Comment(\"2\", \"/test approve\", member, now, now);\n\n        var startedBody = List.of(\n            \"<!-- TEST STARTED -->\",\n            \"<!-- 0 -->\",\n            \"A test job has been started with id 0\"\n        );\n        var startedComment = new Comment(\"3\", String.join(\"\\n\", startedBody), bot, now, now);\n\n        var finishedBody = List.of(\n            \"<!-- TEST FINISHED -->\",\n            \"<!-- 0 -->\",\n            \"A test job has been started with id 0\"\n        );\n        var finishedComment = new Comment(\"4\", String.join(\"\\n\", finishedBody), bot, now, now);\n\n        pr.comments = List.of(testComment, pendingComment, approveComment, startedComment, finishedComment);\n\n        var approvers = \"0\";\n        host.groups = Map.of(approvers, Set.of(member));\n\n        var state = State.from(pr, u -> host.isMemberOf(approvers, u));\n        assertEquals(Stage.FINISHED, state.stage());\n        assertEquals(testComment, state.requested());\n        assertEquals(pendingComment, state.pending());\n        assertEquals(startedComment, state.started());\n        assertEquals(finishedComment, state.finished());\n    }\n}\n"
  },
  {
    "path": "bots/tester/src/test/java/org/openjdk/skara/bots/tester/TestBotFactoryTest.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.tester;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.json.JWCC;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestBotFactory;\nimport org.openjdk.skara.test.TestHostedRepository;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass TestBotFactoryTest {\n    @Test\n    public void testCreate() {\n        try (var tempFolder = new TemporaryDirectory()) {\n            String jsonString = \"\"\"\n                    {\n                      \"census\": \"census\",\n                      \"approvers\": \"approver\",\n                      \"allowlist\": [\n                        \"allow1\",\n                        \"allow2\"\n                      ],\n                      \"availableJobs\": [\n                        \"availableJob1\",\n                        \"availableJob2\"\n                      ],\n                      \"defaultJobs\":[\n                        \"defaultJob1\",\n                        \"defaultJob2\"\n                      ],\n                      \"ci\": \"ci_test\",\n                      \"name\": \"name\",\n                      \"repositories\": [\n                        \"repo1\",\n                        \"repo2\"\n                      ],\n                      \"role\": \"role1\"\n                    }\n                    \"\"\";\n            var jsonConfig = JWCC.parse(jsonString).asObject();\n\n            var testBotFactory = TestBotFactory.newBuilder()\n                    .addHostedRepository(\"repo1\", new TestHostedRepository(\"repo1\"))\n                    .addHostedRepository(\"repo2\", new TestHostedRepository(\"repo2\"))\n                    .addHostedRepository(\"census\", new TestHostedRepository(\"census\"))\n                    .storagePath(tempFolder.path().resolve(\"storage\"))\n                    .build();\n\n            var bots = testBotFactory.createBots(org.openjdk.skara.bots.tester.TestBotFactory.NAME, jsonConfig);\n            //A TestBot for every configured repo\n            assertEquals(2, bots.size());\n\n            assertEquals(\"TestBot@repo1\", bots.get(0).toString());\n            assertEquals(\"TestBot@repo2\", bots.get(1).toString());\n        }\n    }\n}"
  },
  {
    "path": "bots/tester/src/test/java/org/openjdk/skara/bots/tester/TestBotTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.tester;\n\nimport org.openjdk.skara.test.*;\n\nimport java.io.*;\nimport java.util.*;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass TestBotTests {\n    @Test\n    void noTestCommentShouldDoNothing(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tmp = new TemporaryDirectory()) {\n            var upstreamHostedRepo = credentials.getHostedRepository();\n            var personalHostedRepo = credentials.getHostedRepository();\n            var pr = personalHostedRepo.createPullRequest(upstreamHostedRepo,\n                                                          \"master\",\n                                                          \"master\",\n                                                          \"Title\",\n                                                          List.of(\"body\"));\n\n            var comments = pr.comments();\n            assertEquals(0, comments.size());\n\n            var storage = tmp.path().resolve(\"storage\");\n            var ci = new InMemoryContinuousIntegration();\n            var bot = new TestBot(ci, \"0\", Set.of(), List.of(), List.of(), \"\",\n                                  storage, upstreamHostedRepo, null, null, false);\n            var runner = new TestBotRunner();\n\n            runner.runPeriodicItems(bot);\n\n            comments = pr.comments();\n            assertEquals(0, comments.size());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/tester/src/test/java/org/openjdk/skara/bots/tester/TestWorkItemTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.tester;\n\nimport org.openjdk.skara.forge.CheckStatus;\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.ci.Job;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.time.ZonedDateTime;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass TestWorkItemTests {\n    @Test\n    void noTestCommentsShouldDoNothing() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            pr.author = duke;\n            pr.comments = List.of();\n\n            var item = new TestWorkItem(ci, approvers, Set.of(), available, defaultJobs, name, storage, pr,\n                                        u -> true);\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(0, comments.size());\n        }\n    }\n\n    @Test\n    void topLevelTestApproveShouldDoNothing() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            var now = ZonedDateTime.now();\n            pr.author = duke;\n            var testApproveComment = new Comment(\"0\", \"/test approve\", duke, now, now);\n            pr.comments = List.of(testApproveComment);\n\n            var item = new TestWorkItem(ci, approvers, Set.of(), available, defaultJobs, name, storage, pr,\n                                        u -> true);\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(1, comments.size());\n            assertEquals(testApproveComment, comments.get(0));\n        }\n    }\n\n    @Test\n    void topLevelTestCancelShouldDoNothing() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            var now = ZonedDateTime.now();\n            pr.author = duke;\n            var testApproveComment = new Comment(\"0\", \"/test cancel\", duke, now, now);\n            pr.comments = List.of(testApproveComment);\n\n            var item = new TestWorkItem(ci, approvers, Set.of(), available, defaultJobs, name, storage, pr,\n                                        u -> true);\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(1, comments.size());\n            assertEquals(testApproveComment, comments.get(0));\n        }\n    }\n\n    @Test\n    void testCommentWithMadeUpJobShouldBeError() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n            host.groups = Map.of(\"0\", Set.of());\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            pr.author = duke;\n\n            var now = ZonedDateTime.now();\n            var comment = new Comment(\"0\", \"/test foobar\", duke, now, now);\n            pr.comments = new ArrayList<>(List.of(comment));\n\n            var item = new TestWorkItem(ci, approvers, Set.of(), available, defaultJobs, name, storage, pr,\n                                        u -> true);\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n\n            var secondComment = comments.get(1);\n            assertEquals(bot, secondComment.author());\n\n            var lines = secondComment.body().split(\"\\n\");\n            assertEquals(2, lines.length);\n            assertEquals(\"<!-- TEST ERROR -->\", lines[0]);\n            assertEquals(\"@duke the test group foobar does not exist\", lines[1]);\n        }\n    }\n\n    @Test\n    void testCommentFromUnapprovedUserShouldBePending() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n            host.groups = Map.of(\"0\", Set.of());\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            pr.author = duke;\n            pr.headHash = new Hash(\"01234567890123456789012345789012345789\");\n\n            var now = ZonedDateTime.now();\n            var comment = new Comment(\"0\", \"/test foobar\", duke, now, now);\n            pr.comments = new ArrayList<>(List.of(comment));\n\n            var item = new TestWorkItem(ci, approvers, Set.of(), available, defaultJobs, name, storage, pr,\n                                        u -> true);\n\n            // Non-existing test group should result in error\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n\n            var secondComment = comments.get(1);\n            assertEquals(bot, secondComment.author());\n\n            var lines = secondComment.body().split(\"\\n\");\n            assertEquals(2, lines.length);\n            assertEquals(\"<!-- TEST ERROR -->\", lines[0]);\n            assertEquals(\"@duke the test group foobar does not exist\", lines[1]);\n\n            // Trying to test again should be fine\n            var thirdComment = new Comment(\"2\", \"/test tier1\", duke, now, now);\n            pr.comments.add(thirdComment);\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(4, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(thirdComment, comments.get(2));\n\n            var fourthComment = comments.get(3);\n            assertEquals(bot, fourthComment.author());\n\n            lines = fourthComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST PENDING -->\", lines[0]);\n            assertEquals(\"<!-- 01234567890123456789012345789012345789 -->\", lines[1]);\n            assertEquals(\"<!-- tier1 -->\", lines[2]);\n            assertEquals(\"@duke you need to get approval to run the tests in tier1 for commits up until 01234567\",\n                         lines[3]);\n\n            // Nothing should change if we run it yet again\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(4, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(thirdComment, comments.get(2));\n            assertEquals(fourthComment, comments.get(3));\n        }\n    }\n\n    @Test\n    void cancelAtestCommentShouldBeCancel() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n            host.groups = Map.of(\"0\", Set.of());\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            pr.author = duke;\n            pr.headHash = new Hash(\"01234567890123456789012345789012345789\");\n\n            var now = ZonedDateTime.now();\n            var testComment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n            var cancelComment = new Comment(\"1\", \"/test cancel\", duke, now, now);\n            pr.comments = new ArrayList<>(List.of(testComment, cancelComment));\n\n            var item = new TestWorkItem(ci, approvers, Set.of(), available, defaultJobs, name, storage, pr,\n                                        u -> true);\n\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(testComment, comments.get(0));\n            assertEquals(cancelComment, comments.get(1));\n        }\n    }\n\n    @Test\n    void cancellingAPendingTestCommentShouldWork() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n            host.groups = Map.of(approvers, Set.of());\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            pr.author = duke;\n            pr.headHash = new Hash(\"01234567890123456789012345789012345789\");\n\n            var now = ZonedDateTime.now();\n            var comment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n            pr.comments = new ArrayList<>(List.of(comment));\n\n            var item = new TestWorkItem(ci, approvers, Set.of(), available, defaultJobs, name, storage, pr,\n                                        u -> true);\n\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n            var secondComment = comments.get(1);\n            assertEquals(bot, secondComment.author());\n\n            var lines = secondComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST PENDING -->\", lines[0]);\n            assertEquals(\"<!-- 01234567890123456789012345789012345789 -->\", lines[1]);\n            assertEquals(\"<!-- tier1 -->\", lines[2]);\n            assertEquals(\"@duke you need to get approval to run the tests in tier1 for commits up until 01234567\",\n                         lines[3]);\n\n            // Nothing should change if we run it yet again\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n\n            // Cancelling the test now should be fine\n            var cancelComment = new Comment(\"2\", \"/test cancel\", duke, now, now);\n            pr.comments.add(cancelComment);\n\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(3, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(cancelComment, comments.get(2));\n\n            // Approving the test should not start a job, it has already been cancelled\n            var member = HostUser.create(3, \"foo\", \"Foo Bar\");\n            host.groups = Map.of(approvers, Set.of(member));\n            var approveComment = new Comment(\"3\", \"/test approve\", member, now, now);\n            pr.comments.add(approveComment);\n\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(4, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(cancelComment, comments.get(2));\n            assertEquals(approveComment, comments.get(3));\n        }\n    }\n\n    @Test\n    void cancellingApprovedPendingRequestShouldBeCancelled() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n            host.groups = Map.of(approvers, Set.of());\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            pr.author = duke;\n            pr.headHash = new Hash(\"01234567890123456789012345789012345789\");\n\n            var now = ZonedDateTime.now();\n            var comment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n            pr.comments = new ArrayList<>(List.of(comment));\n\n            var item = new TestWorkItem(ci, approvers, Set.of(), available, defaultJobs, name, storage, pr,\n                                        u -> true);\n\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n            var secondComment = comments.get(1);\n            assertEquals(bot, secondComment.author());\n\n            var lines = secondComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST PENDING -->\", lines[0]);\n            assertEquals(\"<!-- 01234567890123456789012345789012345789 -->\", lines[1]);\n            assertEquals(\"<!-- tier1 -->\", lines[2]);\n            assertEquals(\"@duke you need to get approval to run the tests in tier1 for commits up until 01234567\",\n                         lines[3]);\n\n            // Nothing should change if we run it yet again\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n\n            // Approve the request\n            var member = HostUser.create(2, \"foo\", \"Foo Bar\");\n            host.groups = Map.of(approvers, Set.of(member));\n            var approveComment = new Comment(\"2\", \"/test approve\", member, now, now);\n            pr.comments.add(approveComment);\n\n            // Cancelling the request\n            var cancelComment = new Comment(\"2\", \"/test cancel\", duke, now, now);\n            pr.comments.add(cancelComment);\n\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(4, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(approveComment, comments.get(2));\n            assertEquals(cancelComment, comments.get(3));\n        }\n    }\n\n    @Test\n    void approvedPendingRequestShouldBeStarted() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var localRepoDir = tmp.path().resolve(\"repository.git\");\n            var localRepo = TestableRepository.init(localRepoDir, VCS.GIT);\n            var readme = localRepoDir.resolve(\"README\");\n            Files.writeString(readme, \"Hello\\n\");\n            localRepo.add(readme);\n            var head = localRepo.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n            host.groups = Map.of(approvers, Set.of());\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n            repo.webUrl = URI.create(\"file://\" + localRepoDir.toAbsolutePath());\n            repo.url = URI.create(\"file://\" + localRepoDir.toAbsolutePath());\n            repo.id = 1337L;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n            pr.id = \"17\";\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            pr.author = duke;\n            pr.headHash = head;\n\n            var now = ZonedDateTime.now();\n            var comment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n            pr.comments = new ArrayList<>(List.of(comment));\n\n            var item = new TestWorkItem(ci, approvers, Set.of(), available, defaultJobs, name, storage, pr,\n                                        u -> true);\n\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n            var secondComment = comments.get(1);\n            assertEquals(bot, secondComment.author());\n\n            var lines = secondComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST PENDING -->\", lines[0]);\n            assertEquals(\"<!-- \" + head.hex() + \" -->\", lines[1]);\n            assertEquals(\"<!-- tier1 -->\", lines[2]);\n            assertEquals(\"@duke you need to get approval to run the tests in tier1 for commits up until \" + head.abbreviate(),\n                         lines[3]);\n\n            // Nothing should change if we run it yet again\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n\n            // Approve the request\n            var member = HostUser.create(2, \"foo\", \"Foo Bar\");\n            host.groups = Map.of(approvers, Set.of(member));\n            var approveComment = new Comment(\"2\", \"/test approve\", member, now, now);\n            pr.comments.add(approveComment);\n\n            var expectedSubmissionId = \"null-1337-17-0\";\n            var expectedId = \"0-17-1337-null\";\n            var expectedJob = new InMemoryJob();\n            expectedJob.id = expectedId;\n            expectedJob.status = new Job.Status(0, 1, 7);\n            ci.jobs.put(expectedId, expectedJob);\n            ci.jobs.put(expectedSubmissionId, expectedJob);\n\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(4, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(approveComment, comments.get(2));\n\n            var fourthComment = comments.get(3);\n            lines = fourthComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST STARTED -->\", lines[0]);\n            assertEquals(\"<!-- \" + expectedId + \" -->\", lines[1]);\n            assertEquals(\"<!-- \" + head.hex() + \" -->\", lines[2]);\n            assertEquals(\"A test job has been started with id: \" + expectedId, lines[3]);\n\n            assertEquals(1, ci.submissions.size());\n            var submission = ci.submissions.get(0);\n            assertTrue(submission.source.startsWith(storage));\n            assertEquals(List.of(\"tier1\"), submission.jobs);\n            assertEquals(expectedSubmissionId, submission.id);\n\n            var checks = pr.checks(pr.headHash());\n            assertEquals(1, checks.keySet().size());\n            var check = checks.get(\"test\");\n            assertEquals(\"Summary\", check.title().get());\n            assertTrue(check.summary()\n                            .get()\n                            .contains(\"0 jobs completed, 1 job running, 7 jobs not yet started\"));\n        }\n    }\n\n    @Test\n    void cancellingApprovedPendingRequestShouldBeCancel() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var localRepoDir = tmp.path().resolve(\"repository.git\");\n            var localRepo = TestableRepository.init(localRepoDir, VCS.GIT);\n            var readme = localRepoDir.resolve(\"README\");\n            Files.writeString(readme, \"Hello\\n\");\n            localRepo.add(readme);\n            var head = localRepo.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n            host.groups = Map.of(approvers, Set.of());\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n            repo.webUrl = URI.create(\"file://\" + localRepoDir.toAbsolutePath());\n            repo.url = URI.create(\"file://\" + localRepoDir.toAbsolutePath());\n            repo.id = 1337L;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n            pr.id = \"17\";\n            pr.headHash = head;\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            pr.author = duke;\n\n            var now = ZonedDateTime.now();\n            var comment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n            pr.comments = new ArrayList<>(List.of(comment));\n\n            var item = new TestWorkItem(ci, approvers, Set.of(), available, defaultJobs, name, storage, pr,\n                                        u -> true);\n\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n            var secondComment = comments.get(1);\n            assertEquals(bot, secondComment.author());\n\n            var lines = secondComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST PENDING -->\", lines[0]);\n            assertEquals(\"<!-- \" + head.hex() + \" -->\", lines[1]);\n            assertEquals(\"<!-- tier1 -->\", lines[2]);\n            assertEquals(\"@duke you need to get approval to run the tests in tier1 for commits up until \" + head.abbreviate(),\n                         lines[3]);\n\n            // Nothing should change if we run it yet again\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n\n            // Approve the request\n            var member = HostUser.create(2, \"foo\", \"Foo Bar\");\n            host.groups = Map.of(approvers, Set.of(member));\n            var approveComment = new Comment(\"2\", \"/test approve\", member, now, now);\n            pr.comments.add(approveComment);\n\n            var expectedSubmissionId = \"null-1337-17-0\";\n            var expectedId = \"0-17-1337-null\";\n            var expectedJob = new InMemoryJob();\n            expectedJob.status = new Job.Status(0, 1, 7);\n            expectedJob.id = expectedId;\n            ci.jobs.put(expectedId, expectedJob);\n            ci.jobs.put(expectedSubmissionId, expectedJob);\n\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(4, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(approveComment, comments.get(2));\n\n            var fourthComment = comments.get(3);\n            lines = fourthComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST STARTED -->\", lines[0]);\n            assertEquals(\"<!-- \" + expectedId + \" -->\", lines[1]);\n            assertEquals(\"<!-- \" + head.hex() + \" -->\", lines[2]);\n            assertEquals(\"A test job has been started with id: \" + expectedId, lines[3]);\n\n            assertEquals(1, ci.submissions.size());\n            var submission = ci.submissions.get(0);\n            assertTrue(submission.source.startsWith(storage));\n            assertEquals(List.of(\"tier1\"), submission.jobs);\n            assertEquals(expectedSubmissionId, submission.id);\n\n            var checks = pr.checks(pr.headHash());\n            assertEquals(1, checks.keySet().size());\n            var check = checks.get(\"test\");\n            assertEquals(\"Summary\", check.title().get());\n            assertEquals(CheckStatus.IN_PROGRESS, check.status());\n            assertTrue(check.summary()\n                            .get()\n                            .contains(\"## Status\\n0 jobs completed, 1 job running, 7 jobs not yet started\\n\"));\n\n            var cancelComment = new Comment(\"4\", \"/test cancel\", duke, now, now);\n            pr.comments.add(cancelComment);\n\n            item.run(scratch);\n\n            checks = pr.checks(pr.headHash());\n            assertEquals(1, checks.keySet().size());\n            check = checks.get(\"test\");\n            assertEquals(\"Summary\", check.title().get());\n            assertEquals(CheckStatus.CANCELLED, check.status());\n            assertTrue(check.summary()\n                            .get()\n                            .contains(\"## Status\\n0 jobs completed, 1 job running, 7 jobs not yet started\\n\"));\n\n            assertEquals(expectedId, ci.cancelled.get(0));\n        }\n    }\n\n    @Test\n    void errorWhenCreatingTestJobShouldResultInError() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var localRepoDir = tmp.path().resolve(\"repository.git\");\n            var localRepo = TestableRepository.init(localRepoDir, VCS.GIT);\n            var readme = localRepoDir.resolve(\"README\");\n            Files.writeString(readme, \"Hello\\n\");\n            localRepo.add(readme);\n            var head = localRepo.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n            host.groups = Map.of(approvers, Set.of());\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n            repo.webUrl = URI.create(\"file://\" + localRepoDir.toAbsolutePath());\n            repo.url = URI.create(\"file://\" + localRepoDir.toAbsolutePath());\n            repo.id = 1337L;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n            pr.id = \"17\";\n            pr.headHash = head;\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            pr.author = duke;\n\n            var now = ZonedDateTime.now();\n            var comment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n            pr.comments = new ArrayList<>(List.of(comment));\n\n            var item = new TestWorkItem(ci, approvers, Set.of(), available, defaultJobs, name, storage, pr,\n                                        u -> true);\n\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n            var secondComment = comments.get(1);\n            assertEquals(bot, secondComment.author());\n\n            var lines = secondComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST PENDING -->\", lines[0]);\n            assertEquals(\"<!-- \" + head.hex() + \" -->\", lines[1]);\n            assertEquals(\"<!-- tier1 -->\", lines[2]);\n            assertEquals(\"@duke you need to get approval to run the tests in tier1 for commits up until \" + head.abbreviate(),\n                         lines[3]);\n\n            // Nothing should change if we run it yet again\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n\n            // Approve the request\n            var member = HostUser.create(2, \"foo\", \"Foo Bar\");\n            host.groups = Map.of(approvers, Set.of(member));\n            var approveComment = new Comment(\"2\", \"/test approve\", member, now, now);\n            pr.comments.add(approveComment);\n\n            ci.throwOnSubmit = true;\n            assertThrows(UncheckedIOException.class, () -> item.run(scratch));\n\n            comments = pr.comments();\n            assertEquals(4, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(approveComment, comments.get(2));\n\n            var fifthComment = comments.get(3);\n            lines = fifthComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST ERROR -->\", lines[0]);\n            assertEquals(\"Could not create test job\", lines[1]);\n        }\n    }\n\n    @Test\n    void finishedJobShouldResultInFinishedComment() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var localRepoDir = tmp.path().resolve(\"repository.git\");\n            var localRepo = TestableRepository.init(localRepoDir, VCS.GIT);\n            var readme = localRepoDir.resolve(\"README\");\n            Files.writeString(readme, \"Hello\\n\");\n            localRepo.add(readme);\n            var head = localRepo.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n            host.groups = Map.of(approvers, Set.of());\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n            repo.webUrl = URI.create(\"file://\" + localRepoDir.toAbsolutePath());\n            repo.url = URI.create(\"file://\" + localRepoDir.toAbsolutePath());\n            repo.id = 1337L;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n            pr.id = \"17\";\n            pr.headHash = head;\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            pr.author = duke;\n\n            var now = ZonedDateTime.now();\n            var comment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n            pr.comments = new ArrayList<>(List.of(comment));\n\n            var item = new TestWorkItem(ci, approvers, Set.of(), available, defaultJobs, name, storage, pr,\n                                        u -> true);\n\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n            var secondComment = comments.get(1);\n            assertEquals(bot, secondComment.author());\n\n            var lines = secondComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST PENDING -->\", lines[0]);\n            assertEquals(\"<!-- \" + head.hex() + \" -->\", lines[1]);\n            assertEquals(\"<!-- tier1 -->\", lines[2]);\n            assertEquals(\"@duke you need to get approval to run the tests in tier1 for commits up until \" + head.abbreviate(),\n                         lines[3]);\n\n            // Nothing should change if we run it yet again\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n\n            // Approve the request\n            var member = HostUser.create(2, \"foo\", \"Foo Bar\");\n            host.groups = Map.of(approvers, Set.of(member));\n            var approveComment = new Comment(\"2\", \"/test approve\", member, now, now);\n            pr.comments.add(approveComment);\n\n            var expectedSubmissionId = \"null-1337-17-0\";\n            var expectedId = \"0-17-1337-null\";\n            var expectedJob = new InMemoryJob();\n            expectedJob.status = new Job.Status(0, 1, 7);\n            expectedJob.id = expectedId;\n            ci.jobs.put(expectedId, expectedJob);\n            ci.jobs.put(expectedSubmissionId, expectedJob);\n\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(4, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(approveComment, comments.get(2));\n\n            var fourthComment = comments.get(3);\n            lines = fourthComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST STARTED -->\", lines[0]);\n            assertEquals(\"<!-- \" + expectedId + \" -->\", lines[1]);\n            assertEquals(\"<!-- \" + head.hex() + \" -->\", lines[2]);\n            assertEquals(\"A test job has been started with id: \" + expectedId, lines[3]);\n\n            assertEquals(1, ci.submissions.size());\n            var submission = ci.submissions.get(0);\n            assertTrue(submission.source.startsWith(storage));\n            assertEquals(List.of(\"tier1\"), submission.jobs);\n            assertEquals(expectedSubmissionId, submission.id);\n\n            var checks = pr.checks(pr.headHash());\n            assertEquals(1, checks.keySet().size());\n            var check = checks.get(\"test\");\n            assertEquals(\"Summary\", check.title().get());\n            assertEquals(CheckStatus.IN_PROGRESS, check.status());\n            assertTrue(check.summary()\n                            .get()\n                            .contains(\"0 jobs completed, 1 job running, 7 jobs not yet started\"));\n\n            var job = ci.jobs.get(expectedId);\n            assertNotNull(job);\n            job.id = \"id\";\n            job.state = Job.State.COMPLETED;\n            job.status = new Job.Status(8, 0, 0);\n            job.result = new Job.Result(8, 0, 0);\n\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(5, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(approveComment, comments.get(2));\n            assertEquals(fourthComment, comments.get(3));\n\n            var finishedComment = comments.get(4);\n            lines = finishedComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST FINISHED -->\", lines[0]);\n            assertEquals(\"<!-- \" + expectedId +\" -->\", lines[1]);\n            assertEquals(\"<!-- \" + head.hex() +\" -->\", lines[2]);\n            assertEquals(\"@duke your test job with id \" + expectedId + \" for commits up until \" +\n                         head.abbreviate() + \" has finished.\", lines[3]);\n\n            checks = pr.checks(pr.headHash());\n            assertEquals(1, checks.keySet().size());\n            check = checks.get(\"test\");\n            assertEquals(\"Summary\", check.title().get());\n            assertEquals(CheckStatus.SUCCESS, check.status());\n\n            var summaryLines = check.summary().get().split(\"\\n\");\n            assertEquals(\"## Id\", summaryLines[0]);\n            assertEquals(\"`id`\", summaryLines[1]);\n            assertEquals(\"\", summaryLines[2]);\n            assertEquals(\"## Builds\", summaryLines[3]);\n            assertEquals(\"\", summaryLines[4]);\n            assertEquals(\"## Tests\", summaryLines[5]);\n            assertEquals(\"\", summaryLines[6]);\n            assertEquals(\"## Status\", summaryLines[7]);\n            assertEquals(\"8 jobs completed, 0 jobs running, 0 jobs not yet started\", summaryLines[8]);\n            assertEquals(\"\", summaryLines[9]);\n            assertEquals(\"## Result\", summaryLines[10]);\n            assertEquals(\"8 jobs passed, 0 jobs with failures, 0 jobs not run\", summaryLines[11]);\n        }\n    }\n\n    @Test\n    void userOnApprovelistDoesNotNeedApproval() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var localRepoDir = tmp.path().resolve(\"repository.git\");\n            var localRepo = TestableRepository.init(localRepoDir, VCS.GIT);\n            var readme = localRepoDir.resolve(\"README\");\n            Files.writeString(readme, \"Hello\\n\");\n            localRepo.add(readme);\n            var head = localRepo.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n            host.groups = Map.of(approvers, Set.of());\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n            repo.webUrl = URI.create(\"file://\" + localRepoDir.toAbsolutePath());\n            repo.url = URI.create(\"file://\" + localRepoDir.toAbsolutePath());\n            repo.id = 1337L;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n            pr.id = \"17\";\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            pr.author = duke;\n            pr.headHash = head;\n\n            var now = ZonedDateTime.now();\n            var comment = new Comment(\"0\", \"/test tier1\", duke, now, now);\n            pr.comments = new ArrayList<>(List.of(comment));\n\n            var item = new TestWorkItem(ci, approvers, Set.of(\"0\"), available, defaultJobs, name, storage, pr,\n                                        u -> true);\n\n            var expectedSubmissionId = \"null-1337-17-0\";\n            var expectedId = \"0-17-1337-null\";\n            var expectedJob = new InMemoryJob();\n            expectedJob.status = new Job.Status(0, 1, 7);\n            expectedJob.id = expectedId;\n            ci.jobs.put(expectedId, expectedJob);\n            ci.jobs.put(expectedSubmissionId, expectedJob);\n\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n            var secondComment = comments.get(1);\n            assertEquals(bot, secondComment.author());\n\n            var lines = secondComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST STARTED -->\", lines[0]);\n            assertEquals(\"<!-- \" + expectedId + \" -->\", lines[1]);\n            assertEquals(\"<!-- \" + head.hex() + \" -->\", lines[2]);\n            assertEquals(\"A test job has been started with id: \" + expectedId, lines[3]);\n\n            assertEquals(1, ci.submissions.size());\n            var submission = ci.submissions.get(0);\n            assertTrue(submission.source.startsWith(storage));\n            assertEquals(List.of(\"tier1\"), submission.jobs);\n            assertEquals(expectedSubmissionId, submission.id);\n\n            var checks = pr.checks(pr.headHash());\n            assertEquals(1, checks.keySet().size());\n            var check = checks.get(\"test\");\n            assertEquals(\"Summary\", check.title().get());\n            assertTrue(check.summary()\n                            .get()\n                            .contains(\"0 jobs completed, 1 job running, 7 jobs not yet started\"));\n        }\n    }\n\n    @Test\n    void testCommentFromNonCommitterShouldRequireApproval() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            host.groups = Map.of(approvers, Set.of(duke));\n            pr.author = duke;\n            pr.headHash = new Hash(\"01234567890123456789012345789012345789\");\n\n            var now = ZonedDateTime.now();\n            var comment = new Comment(\"0\", \"/test foobar\", duke, now, now);\n            pr.comments = new ArrayList<>(List.of(comment));\n\n            var item = new TestWorkItem(ci, approvers, Set.of(), available, defaultJobs, name, storage, pr,\n                                        u -> false);\n\n            // Non-existing test group should result in error\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n\n            var secondComment = comments.get(1);\n            assertEquals(bot, secondComment.author());\n\n            var lines = secondComment.body().split(\"\\n\");\n            assertEquals(2, lines.length);\n            assertEquals(\"<!-- TEST ERROR -->\", lines[0]);\n            assertEquals(\"@duke the test group foobar does not exist\", lines[1]);\n\n            // Trying to test again should be fine\n            var thirdComment = new Comment(\"2\", \"/test tier1\", duke, now, now);\n            pr.comments.add(thirdComment);\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(4, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(thirdComment, comments.get(2));\n\n            var fourthComment = comments.get(3);\n            assertEquals(bot, fourthComment.author());\n\n            lines = fourthComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST PENDING -->\", lines[0]);\n            assertEquals(\"<!-- 01234567890123456789012345789012345789 -->\", lines[1]);\n            assertEquals(\"<!-- tier1 -->\", lines[2]);\n            assertEquals(\"@duke you need to get approval to run the tests in tier1 for commits up until 01234567\",\n                         lines[3]);\n\n            // Nothing should change if we run it yet again\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(4, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(thirdComment, comments.get(2));\n            assertEquals(fourthComment, comments.get(3));\n        }\n    }\n\n    @Test\n    void pendingTestShouldHaveTestRequestLabel() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            host.groups = Map.of(approvers, Set.of(duke));\n            pr.author = duke;\n            pr.headHash = new Hash(\"01234567890123456789012345789012345789\");\n\n            var now = ZonedDateTime.now();\n            var comment = new Comment(\"0\", \"/test foobar\", duke, now, now);\n            pr.comments = new ArrayList<>(List.of(comment));\n\n            var item = new TestWorkItem(ci, approvers, Set.of(), available, defaultJobs, name, storage, pr,\n                                        u -> false);\n\n            // Non-existing test group should result in error\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n\n            var secondComment = comments.get(1);\n            assertEquals(bot, secondComment.author());\n\n            var lines = secondComment.body().split(\"\\n\");\n            assertEquals(2, lines.length);\n            assertEquals(\"<!-- TEST ERROR -->\", lines[0]);\n            assertEquals(\"@duke the test group foobar does not exist\", lines[1]);\n\n            // Trying to test again should be fine\n            var thirdComment = new Comment(\"2\", \"/test tier1\", duke, now, now);\n            pr.comments.add(thirdComment);\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(4, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(thirdComment, comments.get(2));\n\n            var fourthComment = comments.get(3);\n            assertEquals(bot, fourthComment.author());\n\n            lines = fourthComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST PENDING -->\", lines[0]);\n            assertEquals(\"<!-- 01234567890123456789012345789012345789 -->\", lines[1]);\n            assertEquals(\"<!-- tier1 -->\", lines[2]);\n            assertEquals(\"@duke you need to get approval to run the tests in tier1 for commits up until 01234567\",\n                         lines[3]);\n\n            assertEquals(List.of(\"test-request\"), pr.labelNames());\n\n            // Nothing should change if we run it yet again\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(4, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(thirdComment, comments.get(2));\n            assertEquals(fourthComment, comments.get(3));\n        }\n    }\n\n    @Test\n    void cancelledPendingTestShouldNotHaveTestRequestLabel() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var ci = new InMemoryContinuousIntegration();\n            var approvers = \"0\";\n            var available = List.of(\"tier1\", \"tier2\", \"tier3\");\n            var defaultJobs = List.of(\"tier1\");\n            var name = \"test\";\n            var storage = tmp.path().resolve(\"storage\");\n            var scratch = tmp.path().resolve(\"storage\");\n\n            var bot = HostUser.create(1, \"bot\", \"openjdk [bot]\");\n            var host = new InMemoryHost();\n            host.currentUserDetails = bot;\n\n            var repo = new InMemoryHostedRepository();\n            repo.host = host;\n\n            var pr = new InMemoryPullRequest();\n            pr.repository = repo;\n\n            var duke = HostUser.create(0, \"duke\", \"Duke\");\n            host.groups = Map.of(approvers, Set.of(duke));\n            pr.author = duke;\n            pr.headHash = new Hash(\"01234567890123456789012345789012345789\");\n\n            var now = ZonedDateTime.now();\n            var comment = new Comment(\"0\", \"/test foobar\", duke, now, now);\n            pr.comments = new ArrayList<>(List.of(comment));\n\n            var item = new TestWorkItem(ci, approvers, Set.of(), available, defaultJobs, name, storage, pr,\n                                        u -> false);\n\n            // Non-existing test group should result in error\n            item.run(scratch);\n\n            var comments = pr.comments();\n            assertEquals(2, comments.size());\n            assertEquals(comment, comments.get(0));\n\n            var secondComment = comments.get(1);\n            assertEquals(bot, secondComment.author());\n\n            var lines = secondComment.body().split(\"\\n\");\n            assertEquals(2, lines.length);\n            assertEquals(\"<!-- TEST ERROR -->\", lines[0]);\n            assertEquals(\"@duke the test group foobar does not exist\", lines[1]);\n\n            // Trying to test again should be fine\n            var thirdComment = new Comment(\"2\", \"/test tier1\", duke, now, now);\n            pr.comments.add(thirdComment);\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(4, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(thirdComment, comments.get(2));\n\n            var fourthComment = comments.get(3);\n            assertEquals(bot, fourthComment.author());\n\n            lines = fourthComment.body().split(\"\\n\");\n            assertEquals(\"<!-- TEST PENDING -->\", lines[0]);\n            assertEquals(\"<!-- 01234567890123456789012345789012345789 -->\", lines[1]);\n            assertEquals(\"<!-- tier1 -->\", lines[2]);\n            assertEquals(\"@duke you need to get approval to run the tests in tier1 for commits up until 01234567\",\n                         lines[3]);\n\n            assertEquals(List.of(\"test-request\"), pr.labelNames());\n\n            // Nothing should change if we run it yet again\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(4, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(thirdComment, comments.get(2));\n            assertEquals(fourthComment, comments.get(3));\n\n            // Cancelling the test again should remove the test-request label\n            var fifthComment = new Comment(\"4\", \"/test cancel\", duke, now, now);\n            pr.comments.add(fifthComment);\n            item.run(scratch);\n\n            comments = pr.comments();\n            assertEquals(5, comments.size());\n            assertEquals(comment, comments.get(0));\n            assertEquals(secondComment, comments.get(1));\n            assertEquals(thirdComment, comments.get(2));\n            assertEquals(fourthComment, comments.get(3));\n            assertEquals(fifthComment, comments.get(4));\n\n            assertEquals(List.of(), pr.labelNames());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/testinfo/build.gradle",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.testinfo'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.bots.testinfo' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':bot')\n    implementation project(':ci')\n    implementation project(':vcs')\n    implementation project(':host')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':census')\n    implementation project(':process')\n    implementation project(':json')\n    implementation project(':network')\n    implementation project(':storage')\n    implementation project(':metrics')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "bots/testinfo/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.bots.testinfo {\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.host;\n    requires org.openjdk.skara.network;\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.process;\n    requires org.openjdk.skara.storage;\n    requires java.logging;\n\n    provides org.openjdk.skara.bot.BotFactory with org.openjdk.skara.bots.testinfo.TestInfoBotFactory;\n}\n"
  },
  {
    "path": "bots/testinfo/src/main/java/org/openjdk/skara/bots/testinfo/TestInfoBot.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\npackage org.openjdk.skara.bots.testinfo;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.forge.*;\n\nimport java.time.*;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.logging.Logger;\n\n/**\n * The TestInfoBot copies 'checks' from the source repository of a PR to the\n * PR itself. In GitHub, these checks are usually workflow/action runs which\n * users may have activated on their personal forks. By copying them to a PR,\n * reviewers can easily see the status of the last workflow runs directly in\n * the PR.\n * <p>\n * The bot polls for work using the standard PullRequestPoller, so will\n * process any updated PR. Depending on the outcome of this processing, the\n * TestInfoBotWorkItem calls back to the bot with a re-check request which\n * causes the bot to submit that PR again after the specified amount of time,\n * or earlier if another change the PR has been detected.\n * <p>\n * Note that if there is a check update, this will cause an update to the PR\n * that the next call to updatedPullRequests, and subsequently getPeriodicItems\n * will also include. So as long as there are check updates, there will\n * essentially be a series of rechecks with only the getPeriodicItem call delay\n * until no update was found, at which point the bot would fall back to adding\n * the retry interval again.\n */\npublic class TestInfoBot implements Bot {\n    private final HostedRepository repo;\n    private final PullRequestPoller poller;\n\n    TestInfoBot(HostedRepository repo) {\n        this.repo = repo;\n        this.poller = new PullRequestPoller(repo, true);\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        var prs = poller.updatedPullRequests();\n        var workItems = prs.stream()\n                .filter(pr -> pr.sourceRepository().isPresent())\n                .map(pr -> (WorkItem) new TestInfoBotWorkItem(pr,\n                        delay -> poller.retryPullRequest(pr, Instant.now().plus(delay))))\n                .toList();\n        poller.lastBatchHandled();\n        return workItems;\n    }\n\n    @Override\n    public String name() {\n        return TestInfoBotFactory.NAME;\n    }\n\n    @Override\n    public String toString() {\n        return \"TestInfoBot@\" + repo.name();\n    }\n}\n"
  },
  {
    "path": "bots/testinfo/src/main/java/org/openjdk/skara/bots/testinfo/TestInfoBotFactory.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.testinfo;\n\nimport org.openjdk.skara.bot.*;\n\nimport java.util.*;\nimport java.util.logging.Logger;\n\npublic class TestInfoBotFactory implements BotFactory {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");\n\n    static final String NAME = \"testinfo\";\n    @Override\n    public String name() {\n        return NAME;\n    }\n\n    @Override\n    public List<Bot> create(BotConfiguration configuration) {\n        var bots = new ArrayList<Bot>();\n        var specific = configuration.specific();\n        for (var repo : specific.get(\"repositories\").asArray()) {\n            bots.add(new TestInfoBot(configuration.repository(repo.asString())));\n        }\n        return bots;\n    }\n}\n"
  },
  {
    "path": "bots/testinfo/src/main/java/org/openjdk/skara/bots/testinfo/TestInfoBotWorkItem.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.testinfo;\n\nimport org.openjdk.skara.bot.WorkItem;\nimport org.openjdk.skara.forge.*;\n\nimport java.nio.file.Path;\nimport java.time.*;\nimport java.util.*;\nimport java.util.function.Consumer;\nimport java.util.logging.Logger;\n\npublic class TestInfoBotWorkItem implements WorkItem {\n    private final PullRequest pr;\n    // This is a callback to the bot telling it that this PR needs a recheck after the\n    // specified duration. If this isn't called, then the PR will only be rechecked if\n    // it is updated by someone else.\n    private final Consumer<Duration> retry;\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");\n\n    TestInfoBotWorkItem(PullRequest pr, Consumer<Duration> retry) {\n        this.pr = pr;\n        this.retry = retry;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof TestInfoBotWorkItem)) {\n            return true;\n        }\n        var o = (TestInfoBotWorkItem) other;\n        return !o.pr.webUrl().equals(pr.webUrl());\n    }\n\n    @Override\n    public String toString() {\n        return \"TestInfoBotWorkItem@\" + pr.repository().name() + \"#\" + pr.id();\n    }\n\n    private Check testingNotConfiguredNotice(PullRequest pr) {\n        var sourceRepoUrl = pr.sourceRepository().orElseThrow().nonTransformedWebUrl().toString();\n        if (pr.sourceRepository().orElseThrow().forge().name().equals(\"GitHub\")) {\n            sourceRepoUrl += \"/actions\";\n        }\n\n        return CheckBuilder.create(\"Pre-submit test status\", pr.headHash())\n                           .skipped()\n                           .title(\"Testing is not configured\")\n                           .summary(\"In order to run pre-submit tests, the [source repository](\" +\n                                            sourceRepoUrl + \")\" +\n                                            \" must be properly configured to allow test execution. \" +\n                                            \"See https://wiki.openjdk.org/display/SKARA/Testing for more information on how to configure this.\")\n                           .build();\n    }\n\n    private Check testingEnabledNotice(PullRequest pr) {\n        return CheckBuilder.create(\"Pre-submit test status\", pr.headHash())\n                           .complete(true)\n                           .title(\"Tests are now enabled\")\n                           .summary(\"Pre-submit tests have been now been enabled for the source repository\")\n                           .build();\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratch) {\n        Optional<HostedRepository> optionalSourceRepository = pr.sourceRepository();\n        if (optionalSourceRepository.isEmpty()) {\n            return List.of();\n        }\n        var sourceRepo = optionalSourceRepository.get();\n        var sourceChecks = sourceRepo.allChecks(pr.headHash());\n\n        var targetChecks = pr.checks(pr.headHash());\n        var noticeCheck = targetChecks.get(\"Pre-submit test status\");\n\n        if (sourceRepo.workflowStatus() == WorkflowStatus.NOT_CONFIGURED) {\n            if (noticeCheck == null) {\n                pr.createCheck(testingNotConfiguredNotice(pr));\n            }\n            // It's pretty unlikely that a user suddenly enables workflows. We can\n            // be pretty lax with automatically discovering this. Touching the PR\n            // will always trigger an immediate recheck anyway.\n            if (pr.isOpen()) {\n                retry.accept(Duration.ofMinutes(30));\n            }\n        } else if (sourceRepo.workflowStatus() == WorkflowStatus.DISABLED) {\n            // Explicitly disabled - could possibly post a notice\n        } else {\n            var summarizedChecks = TestResults.summarize(sourceChecks);\n            if (summarizedChecks.isEmpty()) {\n                // No test related checks found, they may not have started yet, so we'll keep\n                // looking as long as the PR is open.\n                log.fine(\"No checks found to summarize - waiting\");\n                if (pr.isOpen()) {\n                    retry.accept(Duration.ofMinutes(2));\n                }\n            } else {\n                Optional<Duration> expiresIn = TestResults.expiresIn(sourceChecks);\n                if (expiresIn.isPresent()) {\n                    // Workflow is currently running, recheck often to update, but revert\n                    // to longer recheck intervals if the PR hasn't been updated in the\n                    // last 24h and is still open.\n                    if (pr.updatedAt().isAfter(ZonedDateTime.now().minus(Duration.ofDays(1)))) {\n                        retry.accept(expiresIn.get());\n                    } else if (pr.isOpen()) {\n                        retry.accept(Duration.ofMinutes(30));\n                    }\n                } else if (pr.isOpen()) {\n                    // All current checks are finished, as long as PR is open, keep rechecking\n                    // at regular, but much longer intervals.\n                    retry.accept(Duration.ofMinutes(30));\n                }\n            }\n\n            if (noticeCheck != null && noticeCheck.status() == CheckStatus.SKIPPED) {\n                // If a disabled notice has been posted earlier, we can't delete it - just mark it completed\n                pr.updateCheck(testingEnabledNotice(pr));\n            }\n\n            for (var check : summarizedChecks) {\n                if (!targetChecks.containsKey(check.name())) {\n                    pr.createCheck(check);\n                    targetChecks.put(check.name(), check);\n                }\n                var current = targetChecks.get(check.name());\n                if ((current.status() != check.status()) ||\n                        (!current.summary().equals(check.summary())) ||\n                        (!current.title().equals(check.title()))) {\n                    pr.updateCheck(check);\n                } else {\n                    log.fine(\"Not updating unchanged check: \" + check.name());\n                }\n            }\n        }\n\n        return List.of();\n    }\n\n    @Override\n    public String botName() {\n        return TestInfoBotFactory.NAME;\n    }\n\n    @Override\n    public String workItemName() {\n        return botName();\n    }\n\n    @Override\n    public void handleRuntimeException(RuntimeException e) {\n        retry.accept(Duration.ofMinutes(2));\n    }\n}\n"
  },
  {
    "path": "bots/testinfo/src/main/java/org/openjdk/skara/bots/testinfo/TestResults.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.testinfo;\n\nimport org.openjdk.skara.forge.*;\n\nimport java.time.*;\nimport java.util.*;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\n\npublic class TestResults {\n    private static String platformFromName(String checkName) {\n        var checkFlavorStart = checkName.indexOf(\"(\");\n        if (checkFlavorStart > 0) {\n            return checkName.substring(0, checkFlavorStart - 1).strip();\n        } else {\n            return checkName.strip();\n        }\n    }\n\n    private static String flavorFromName(String checkName) {\n        var checkFlavorStart = checkName.indexOf(\"(\");\n        var checkFlavorEnd = checkName.lastIndexOf(\")\");\n        if (checkFlavorStart > 0 && checkFlavorEnd > checkFlavorStart) {\n            var flavor = checkName.substring(checkFlavorStart + 1, checkFlavorEnd).strip().toLowerCase();\n            for (int i = 1; i < 10; ++i) {\n                if (flavor.contains(\"tier\" + i)) {\n                    return \"Test (tier\" + i + \")\";\n                }\n            }\n            if (flavor.contains(\"build\")) {\n                return \"Build\";\n            }\n        }\n        // Fallback value\n        return \"Build / test\";\n    }\n\n    private static boolean ignoredCheck(String checkName) {\n        var lcName = checkName.toLowerCase();\n        return lcName.contains(\"jcheck\") || lcName.contains(\"prerequisites\") || lcName.contains(\"post-process\");\n    }\n\n    // Retain only the latest when there are multiple checks with the same name\n    private static Collection<Check> latestChecks(List<Check> checks) {\n        var latestChecks = checks.stream()\n                                 .filter(check -> !ignoredCheck(check.name()))\n                                 .filter(check -> check.status() != CheckStatus.CANCELLED)\n                                 .sorted(Comparator.comparing(Check::startedAt, ZonedDateTime::compareTo))\n                                 .collect(Collectors.toMap(Check::name, Function.identity(), (a, b) -> b, LinkedHashMap::new));\n        return latestChecks.values();\n    }\n\n    static List<Check> summarize(List<Check> checks) {\n        var latestChecks = latestChecks(checks);\n        if (latestChecks.isEmpty()) {\n            return List.of();\n        }\n\n        var hash = latestChecks.stream().findAny().orElseThrow().hash();\n        var platforms = latestChecks.stream()\n                                    .map(check -> platformFromName(check.name()))\n                                    .collect(Collectors.toCollection(TreeSet::new));\n        var flavors = latestChecks.stream()\n                                  .map(check -> flavorFromName(check.name()))\n                                  .collect(Collectors.toCollection(TreeSet::new));\n        if (platforms.isEmpty() || flavors.isEmpty()) {\n            return List.of();\n        }\n\n        var platformFlavors = latestChecks.stream()\n                                          .collect(Collectors.groupingBy(check -> platformFromName(check.name()))).entrySet().stream()\n                                          .collect(Collectors.toMap(Map.Entry::getKey,\n                                                                    entry -> entry.getValue().stream()\n                                                                                  .collect(Collectors.groupingBy(check -> flavorFromName(check.name())))));\n\n        var ret = new ArrayList<Check>();\n        for (var flavor : flavors) {\n            for (var platform : platforms) {\n                var platformChecks = platformFlavors.get(platform);\n                var flavorChecks = platformChecks.get(flavor);\n                if (flavorChecks != null) {\n                    int failureCount = 0;\n                    int pendingCount = 0;\n                    int successCount = 0;\n                    var checkDetails = new ArrayList<String>();\n                    String checkIcon = \"\";\n                    for (var check : flavorChecks) {\n                        switch (check.status()) {\n                            case IN_PROGRESS:\n                                checkIcon = \"⏳ \";\n                                pendingCount++;\n                                break;\n                            case FAILURE:\n                                checkIcon = \"❌ \";\n                                failureCount++;\n                                break;\n                            case SUCCESS:\n                                checkIcon = \"✔️ \";\n                                successCount++;\n                                break;\n                        }\n                        var checkTitle = check.details().isPresent() ? \"[\" + check.name() + \"](\" + check.details().get() + \")\" : check.name();\n                        checkDetails.add(checkIcon + checkTitle);\n                    }\n                    var checkBuilder = CheckBuilder.create(\"Pre-submit tests - \" + platform + \" - \" + flavor, hash);\n                    checkBuilder.summary(String.join(\"\\n\", checkDetails));\n                    var firstStartedAt = flavorChecks.stream()\n                                                     .map(Check::startedAt)\n                                                     .min(ZonedDateTime::compareTo);\n                    firstStartedAt.ifPresent(checkBuilder::startedAt);\n\n                    var lastCompletedAt = flavorChecks.stream()\n                                                      .map(Check::completedAt)\n                                                      .filter(Optional::isPresent)\n                                                      .map(Optional::get)\n                                                      .max(ZonedDateTime::compareTo);\n                    int total = failureCount + pendingCount + successCount;\n                    if (pendingCount > 0) {\n                        checkBuilder.title(pendingCount + \"/\" + total + \" running\");\n                        ret.add(checkBuilder.build());\n                    } else if (failureCount > 0) {\n                        checkBuilder.title(failureCount + \"/\" + total + \" failed\");\n                        lastCompletedAt.ifPresentOrElse(ca -> checkBuilder.complete(false, ca), () -> checkBuilder.complete(false));\n                        ret.add(checkBuilder.build());\n                    } else if (successCount > 0) {\n                        checkBuilder.title(successCount + \"/\" + total + \" passed\");\n                        lastCompletedAt.ifPresentOrElse(ca -> checkBuilder.complete(true, ca), () -> checkBuilder.complete(true));\n                        ret.add(checkBuilder.build());\n                    }\n                }\n            }\n        }\n\n        return ret;\n    }\n\n    static Optional<Duration> expiresIn(List<Check> checks) {\n        var latestChecks = latestChecks(checks);\n        var needRefresh = latestChecks.stream()\n                                      .filter(check -> check.status() == CheckStatus.IN_PROGRESS)\n                                      .findAny();\n        if (needRefresh.isPresent()) {\n            return Optional.of(Duration.ofMinutes(2));\n        } else {\n            return Optional.empty();\n        }\n    }\n}\n"
  },
  {
    "path": "bots/testinfo/src/test/java/org/openjdk/skara/bots/testinfo/TestInfoBotFactoryTest.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.testinfo;\n\nimport java.util.List;\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.json.JWCC;\nimport org.openjdk.skara.test.TestBotFactory;\nimport org.openjdk.skara.test.TestHost;\nimport org.openjdk.skara.test.TestHostedRepository;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass TestInfoBotFactoryTest {\n    @Test\n    public void testCreate() {\n        String jsonString = \"\"\"\n                {\n                  \"repositories\": [\n                    \"repo1\",\n                    \"repo2\"\n                  ]\n                }\n                \"\"\";\n        var jsonConfig = JWCC.parse(jsonString).asObject();\n\n        var testHost = TestHost.createNew(List.of());\n        var testBotFactory = TestBotFactory.newBuilder()\n                .addHostedRepository(\"repo1\", new TestHostedRepository(testHost, \"repo1\"))\n                .addHostedRepository(\"repo2\", new TestHostedRepository(testHost, \"repo2\"))\n                .build();\n\n        var bots = testBotFactory.createBots(TestInfoBotFactory.NAME, jsonConfig);\n        // A testInfoBot for every configured repo\n        assertEquals(2, bots.size());\n\n        assertEquals(\"TestInfoBot@repo1\", bots.get(0).toString());\n        assertEquals(\"TestInfoBot@repo2\", bots.get(1).toString());\n    }\n}"
  },
  {
    "path": "bots/testinfo/src/test/java/org/openjdk/skara/bots/testinfo/TestInfoTests.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n package org.openjdk.skara.bots.testinfo;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.test.*;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.file.Path;\nimport java.util.Set;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\npublic class TestInfoTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = (TestHostedRepository) credentials.getHostedRepository();\n            var checkBot = new TestInfoBot(author);\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"),\n                                                     Set.of(\"issues\"), null);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add some checks to the repository\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"preedit\", true);\n            var check1 = CheckBuilder.create(\"ps1\", editHash).title(\"PS1\");\n            author.createCheck(check1.complete(true).build());\n            var check2 = CheckBuilder.create(\"ps2\", editHash).title(\"PS2\");\n            author.createCheck(check2.complete(false).build());\n            var check3 = CheckBuilder.create(\"ps3\", editHash).title(\"PS3\");\n            author.createCheck(check3.details(URI.create(\"https://www.example.com\")).complete(false).build());\n            var check4 = CheckBuilder.create(\"ps4\", editHash).title(\"PS4\");\n            author.createCheck(check4.details(URI.create(\"https://www.example.com\")).build());\n\n            // Now make a PR\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify summarized checks\n            assertEquals(4, pr.checks(editHash).size());\n            assertEquals(\"1/1 passed\", pr.checks(editHash).get(\"Pre-submit tests - ps1 - Build / test\").title().orElseThrow());\n            assertEquals(\"✔️ ps1\", pr.checks(editHash).get(\"Pre-submit tests - ps1 - Build / test\").summary().orElseThrow());\n            assertEquals(CheckStatus.SUCCESS, pr.checks(editHash).get(\"Pre-submit tests - ps1 - Build / test\").status());\n            assertEquals(\"1/1 failed\", pr.checks(editHash).get(\"Pre-submit tests - ps2 - Build / test\").title().orElseThrow());\n            assertEquals(\"❌ ps2\", pr.checks(editHash).get(\"Pre-submit tests - ps2 - Build / test\").summary().orElseThrow());\n            assertEquals(CheckStatus.FAILURE, pr.checks(editHash).get(\"Pre-submit tests - ps2 - Build / test\").status());\n            assertEquals(\"1/1 failed\", pr.checks(editHash).get(\"Pre-submit tests - ps3 - Build / test\").title().orElseThrow());\n            assertEquals(\"❌ [ps3](https://www.example.com)\", pr.checks(editHash).get(\"Pre-submit tests - ps3 - Build / test\").summary().orElseThrow());\n            assertEquals(CheckStatus.FAILURE, pr.checks(editHash).get(\"Pre-submit tests - ps3 - Build / test\").status());\n            assertEquals(\"1/1 running\", pr.checks(editHash).get(\"Pre-submit tests - ps4 - Build / test\").title().orElseThrow());\n            assertEquals(\"⏳ [ps4](https://www.example.com)\", pr.checks(editHash).get(\"Pre-submit tests - ps4 - Build / test\").summary().orElseThrow());\n            assertEquals(CheckStatus.IN_PROGRESS, pr.checks(editHash).get(\"Pre-submit tests - ps4 - Build / test\").status());\n        }\n    }\n\n    @Test\n    void update(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var author = (TestHostedRepository) credentials.getHostedRepository();\n            var checkBot = new TestInfoBot(author);\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType(), Path.of(\"appendable.txt\"),\n                                                     Set.of(\"issues\"), null);\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, author.authenticatedUrl(), \"master\", true);\n\n            // Add a check to the repository\n            var editHash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash, author.authenticatedUrl(), \"preedit\", true);\n            var check1 = CheckBuilder.create(\"ps1\", editHash).title(\"PS1\");\n            author.createCheck(check1.complete(true).build());\n\n            // Now make an actual PR\n            localRepo.push(editHash, author.authenticatedUrl(), \"edit\", true);\n            var pr = credentials.createPullRequest(author, \"master\", \"edit\", \"This is a pull request\");\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify summarized checks\n            assertEquals(1, pr.checks(pr.headHash()).size());\n            assertEquals(\"1/1 passed\", pr.checks(pr.headHash()).get(\"Pre-submit tests - ps1 - Build / test\").title().orElseThrow());\n\n            // And a second one\n            var editHash2 = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(editHash2, author.authenticatedUrl(), \"preedit\");\n            var check2 = CheckBuilder.create(\"ps2\", editHash2).title(\"PS2\");\n            author.createCheck(check2.complete(false).build());\n\n            // Push an update to the PR\n            localRepo.push(editHash2, author.authenticatedUrl(), \"edit\", true);\n\n            // Check the status\n            TestBotRunner.runPeriodicItems(checkBot);\n\n            // Verify summarized checks again\n            var updatedPr = pr.repository().pullRequest(pr.id());\n            assertEquals(1, updatedPr.checks(updatedPr.headHash()).size());\n            assertEquals(\"1/1 failed\", updatedPr.checks(updatedPr.headHash()).get(\"Pre-submit tests - ps2 - Build / test\").title().orElseThrow());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/testinfo/src/test/java/org/openjdk/skara/bots/testinfo/TestResultsTests.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.testinfo;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.net.URI;\nimport java.time.*;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\npublic class TestResultsTests {\n    private static final ZonedDateTime baseStartedAt = ZonedDateTime.parse(\"2020-11-26T11:00:00+01:00\", DateTimeFormatter.ISO_ZONED_DATE_TIME);\n\n    private Set<String> checkAsString(List<Check> checks) {\n        return checks.stream()\n                     .map(check -> check.status() + \"##\" +\n                             check.name().substring(19) + \"##\" +\n                             check.title().orElse(\"\") + \"##\" +\n                             check.summary().orElse(\"\") + \"##\" +\n                             Duration.between(baseStartedAt, check.startedAt()).getSeconds() + \"##\" +\n                             Duration.between(baseStartedAt, check.completedAt().orElse(baseStartedAt)).getSeconds())\n                     .collect(Collectors.toSet());\n    }\n\n    @Test\n    void simple() {\n        var check = CheckBuilder.create(\"Test\", Hash.zero())\n                                .startedAt(baseStartedAt)\n                                .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                .build();\n        var summary = TestResults.summarize(List.of(check));\n        assertEquals(Set.of(\"SUCCESS##Test - Build / test##1/1 passed##✔️ Test##0##10\"), checkAsString(summary));\n        assertTrue(TestResults.expiresIn(List.of(check)).isEmpty());\n    }\n\n    @Test\n    void multiPlatform() {\n        var check1 = CheckBuilder.create(\"Linux x64 (test)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var check2 = CheckBuilder.create(\"Windows x64 (test)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var summary = TestResults.summarize(List.of(check1, check2));\n        assertEquals(Set.of(\"SUCCESS##Linux x64 - Build / test##1/1 passed##✔️ Linux x64 (test)##0##10\",\n                            \"SUCCESS##Windows x64 - Build / test##1/1 passed##✔️ Windows x64 (test)##0##10\"),\n                     checkAsString(summary));\n    }\n\n    @Test\n    void multiFlavor() {\n        var check1 = CheckBuilder.create(\"Linux x64 (Build)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var check2 = CheckBuilder.create(\"Linux x64 (Test tier1)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var summary = TestResults.summarize(List.of(check1, check2));\n        assertEquals(Set.of(\"SUCCESS##Linux x64 - Build##1/1 passed##✔️ Linux x64 (Build)##0##10\",\n                            \"SUCCESS##Linux x64 - Test (tier1)##1/1 passed##✔️ Linux x64 (Test tier1)##0##10\"),\n                     checkAsString(summary));\n    }\n\n    @Test\n    void multiEverything() {\n        var check1 = CheckBuilder.create(\"Linux x64 (Build)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var check2 = CheckBuilder.create(\"Linux x64 (Test tier1)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var check3 = CheckBuilder.create(\"Windows x64 (Build)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var check4 = CheckBuilder.create(\"Windows x64 (Test tier1)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var summary = TestResults.summarize(List.of(check1, check2, check3, check4));\n        assertEquals(Set.of(\"SUCCESS##Linux x64 - Build##1/1 passed##✔️ Linux x64 (Build)##0##10\",\n                            \"SUCCESS##Windows x64 - Test (tier1)##1/1 passed##✔️ Windows x64 (Test tier1)##0##10\",\n                            \"SUCCESS##Linux x64 - Test (tier1)##1/1 passed##✔️ Linux x64 (Test tier1)##0##10\",\n                            \"SUCCESS##Windows x64 - Build##1/1 passed##✔️ Windows x64 (Build)##0##10\"),\n                     checkAsString(summary));\n    }\n\n    @Test\n    void sparse() {\n        var check1 = CheckBuilder.create(\"Linux x64 (Build)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var check2 = CheckBuilder.create(\"Linux x64 (Test tier1)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var check3 = CheckBuilder.create(\"Windows x64 (Build)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var check4 = CheckBuilder.create(\"macOS x64 (Build)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var summary = TestResults.summarize(List.of(check1, check2, check3, check4));\n        assertEquals(Set.of(\"SUCCESS##Linux x64 - Build##1/1 passed##✔️ Linux x64 (Build)##0##10\",\n                            \"SUCCESS##Linux x64 - Test (tier1)##1/1 passed##✔️ Linux x64 (Test tier1)##0##10\",\n                            \"SUCCESS##macOS x64 - Build##1/1 passed##✔️ macOS x64 (Build)##0##10\",\n                            \"SUCCESS##Windows x64 - Build##1/1 passed##✔️ Windows x64 (Build)##0##10\"),\n                     checkAsString(summary));\n    }\n\n    @Test\n    void failure() {\n        var check1 = CheckBuilder.create(\"Linux x64 (test)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var check2 = CheckBuilder.create(\"Windows x64 (test)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(false, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .details(URI.create(\"www.example.com\"))\n                                 .build();\n        var summary = TestResults.summarize(List.of(check1, check2));\n        assertEquals(Set.of(\"SUCCESS##Linux x64 - Build / test##1/1 passed##✔️ Linux x64 (test)##0##10\",\n                            \"FAILURE##Windows x64 - Build / test##1/1 failed##❌ [Windows x64 (test)](www.example.com)##0##10\"),\n                     checkAsString(summary));\n    }\n\n    @Test\n    void inProgress() {\n        var check1 = CheckBuilder.create(\"Linux x64 (test)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var check2 = CheckBuilder.create(\"Windows x64 (test)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .build();\n        var summary = TestResults.summarize(List.of(check1, check2));\n        assertEquals(Set.of(\"SUCCESS##Linux x64 - Build / test##1/1 passed##✔️ Linux x64 (test)##0##10\",\n                            \"IN_PROGRESS##Windows x64 - Build / test##1/1 running##⏳ Windows x64 (test)##0##0\"),\n                     checkAsString(summary));\n        assertTrue(TestResults.expiresIn(List.of(check1, check2)).isPresent());\n    }\n\n    @Test\n    void ignored() {\n        var check1 = CheckBuilder.create(\"jcheck\", Hash.zero())\n                                 .complete(true)\n                                 .build();\n        var check2 = CheckBuilder.create(\"Prerequisites\", Hash.zero())\n                                 .build();\n        var summary = TestResults.summarize(List.of(check1, check2));\n        assertTrue(summary.isEmpty());\n    }\n\n    @Test\n    void ignoredAndCancelled() {\n        var check1 = CheckBuilder.create(\"Prerequisites\", Hash.zero())\n                                 .complete(true)\n                                 .build();\n        var check2 = CheckBuilder.create(\"Post-process artifacts\", Hash.zero())\n                                 .build();\n        var check3 = CheckBuilder.create(\"Linux x64\", Hash.zero())\n                                 .cancel()\n                                 .build();\n        var summary = TestResults.summarize(List.of(check1, check2, check3));\n        assertTrue(summary.isEmpty());\n    }\n\n    @Test\n    void mixed() {\n        var check1 = CheckBuilder.create(\"Linux x64 (Build)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var check2 = CheckBuilder.create(\"Linux x64 (Test tier1)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var check3 = CheckBuilder.create(\"Prerequisites\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var check4 = CheckBuilder.create(\"Post-process\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .build();\n        var summary = TestResults.summarize(List.of(check1, check2, check3, check4));\n        assertEquals(Set.of(\"SUCCESS##Linux x64 - Test (tier1)##1/1 passed##✔️ Linux x64 (Test tier1)##0##10\",\n                            \"SUCCESS##Linux x64 - Build##1/1 passed##✔️ Linux x64 (Build)##0##10\"), checkAsString(summary));\n    }\n\n    @Test\n    void durations() {\n        var check1 = CheckBuilder.create(\"Linux x64 (build release)\", Hash.zero())\n                                 .startedAt(baseStartedAt)\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(400)))\n                                 .build();\n        var check2 = CheckBuilder.create(\"Linux x64 (build debug)\", Hash.zero())\n                                 .startedAt(baseStartedAt.plus(Duration.ofSeconds(60)))\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(120)))\n                                 .build();\n        var check3 = CheckBuilder.create(\"Windows x64 (Build release)\", Hash.zero())\n                                 .startedAt(baseStartedAt.plus(Duration.ofSeconds(10)))\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(20)))\n                                 .build();\n        var check4 = CheckBuilder.create(\"Windows x64 (Build debug)\", Hash.zero())\n                                 .startedAt(baseStartedAt.plus(Duration.ofSeconds(15)))\n                                 .complete(true, baseStartedAt.plus(Duration.ofSeconds(200)))\n                                 .build();\n        var summary = TestResults.summarize(List.of(check1, check2, check3, check4));\n        assertEquals(Set.of(\"SUCCESS##Windows x64 - Build##2/2 passed##✔️ Windows x64 (Build release)\\n\" +\n                                    \"✔️ Windows x64 (Build debug)##10##200\",\n                            \"SUCCESS##Linux x64 - Build##2/2 passed##✔️ Linux x64 (build release)\\n\" +\n                                    \"✔️ Linux x64 (build debug)##0##400\"),\n                     checkAsString(summary));\n    }\n}\n"
  },
  {
    "path": "bots/topological/build.gradle",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.bots.topological'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.junit.jupiter.params'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.bots.topological' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':ci')\n    implementation project(':host')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':bot')\n    implementation project(':census')\n    implementation project(':json')\n    implementation project(':vcs')\n    implementation project(':metrics')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "bots/topological/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.bots.topological {\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.json;\n    requires java.logging;\n\n    provides org.openjdk.skara.bot.BotFactory with org.openjdk.skara.bots.topological.TopologicalBotFactory;\n}\n"
  },
  {
    "path": "bots/topological/src/main/java/org/openjdk/skara/bots/topological/Edge.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.topological;\n\nimport org.openjdk.skara.vcs.Branch;\n\nimport java.util.Objects;\n\nclass Edge {\n    final Branch from;\n    final Branch to;\n\n    Edge(Branch from, Branch to) {\n        this.from = from;\n        this.to = to;\n    }\n\n    @Override\n    public String toString() {\n        return \"Edge{\" +\n                \"from='\" + from + '\\'' +\n                \", to='\" + to + '\\'' +\n                '}';\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) return true;\n        if (o == null || getClass() != o.getClass()) return false;\n        Edge edge = (Edge) o;\n        return Objects.equals(from, edge.from) &&\n                Objects.equals(to, edge.to);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(from, to);\n    }\n}\n"
  },
  {
    "path": "bots/topological/src/main/java/org/openjdk/skara/bots/topological/TopologicalBot.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.topological;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Path;\nimport java.nio.file.Files;\nimport java.net.URLEncoder;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\n\n/**\n * Bot that automatically merges any changes from a dependency branch into a target branch\n */\nclass TopologicalBot implements Bot, WorkItem {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");\n    private final Path storage;\n    private final HostedRepository hostedRepo;\n    private final List<Branch> branches;\n    private final String depsFileName;\n\n    TopologicalBot(Path storage, HostedRepository repo, List<Branch> branches, String depsFileName) {\n        this.storage = storage;\n        this.hostedRepo = repo;\n        this.branches = branches;\n        this.depsFileName = depsFileName;\n    }\n\n    @Override\n    public boolean concurrentWith(WorkItem other) {\n        if (!(other instanceof TopologicalBot otherBot)) {\n            return true;\n        }\n        return !hostedRepo.name().equals(otherBot.hostedRepo.name());\n    }\n\n    @Override\n    public Collection<WorkItem> run(Path scratchPath) {\n        log.info(\"Starting topobot run\");\n        try {\n            var sanitizedUrl = URLEncoder.encode(hostedRepo.webUrl().toString(), StandardCharsets.UTF_8);\n            var dir = storage.resolve(sanitizedUrl);\n            Repository repo;\n            if (!Files.exists(dir)) {\n                log.info(\"Cloning \" + hostedRepo.name());\n                Files.createDirectories(dir);\n                repo = Repository.clone(hostedRepo.authenticatedUrl(), dir);\n            } else {\n                log.info(\"Found existing scratch directory for \" + hostedRepo.name());\n                repo = Repository.get(dir)\n                        .orElseThrow(() -> new RuntimeException(\"Repository in \" + dir + \" has vanished\"));\n            }\n\n            repo.fetchAllRemotes(false);\n            var depsFile = repo.root().resolve(depsFileName);\n\n            var orderedBranches = orderedBranches(repo, depsFile);\n            log.info(\"Merge order \" + orderedBranches);\n            for (var branch : orderedBranches) {\n                log.info(\"Processing branch \" + branch + \"...\");\n                repo.checkout(branch);\n                var parents = dependencies(repo, repo.head(), depsFile).collect(Collectors.toSet());\n                List<String> failedMerges = new ArrayList<>();\n                boolean progress;\n                boolean failed;\n                do {\n                    // We need to attempt merge parents in any order that works. Keep merging\n                    // and pushing, until no further progress can be made.\n                    progress = false;\n                    failed = false;\n                    for (var parentsIt = parents.iterator(); parentsIt.hasNext();) {\n                        var parent = parentsIt.next();\n                        try {\n                            mergeIfAhead(repo, branch, parent);\n                            progress = true;\n                            parentsIt.remove(); // avoid doing pointless merges\n                        } catch(IOException e) {\n                            log.severe(\"Merge with \" + parent + \" failed. Reverting...\");\n                            repo.abortMerge();\n                            failedMerges.add(branch + \" <- \" + parent);\n                            failed = true;\n                        }\n                    }\n                } while(progress && failed);\n\n                if (!failedMerges.isEmpty()) {\n                    throw new IOException(\"There were failed merges:\\n\" + failedMerges);\n                }\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        log.info(\"Ending topobot run\");\n        return List.of();\n    }\n\n    private static Stream<Branch> dependencies(Repository repo, Hash hash, Path depsFile) throws IOException {\n        return repo.lines(depsFile, hash).map(l -> {\n            var lines = l.stream().filter(s -> !s.isEmpty()).collect(Collectors.toList());\n            if (lines.size() > 1) {\n                throw new IllegalStateException(\"Multiple non-empty lines in \" + depsFile.toString() + \": \"\n                        + String.join(\"\\n\", lines));\n            }\n            return Stream.of(lines.get(0).split(\" \")).map(Branch::new);\n        })\n        .orElse(Stream.of(repo.defaultBranch()));\n    }\n\n    private List<Branch> orderedBranches(Repository repo, Path depsFile) throws IOException {\n        List<Edge> deps = new ArrayList<>();\n        for (var branch : branches) {\n            dependencies(repo, repo.resolve(\"origin/\" + branch.name()).orElseThrow(), depsFile)\n                    .forEach(dep -> deps.add(new Edge(dep, branch)));\n        }\n        var defaultBranch = repo.defaultBranch();\n        return TopologicalSort.sort(deps).stream()\n            .filter(branch -> !branch.equals(defaultBranch))\n            .collect(Collectors.toList());\n    }\n\n    private void mergeIfAhead(Repository repo, Branch branch, Branch parent) throws IOException {\n        var fromHash = repo.resolve(parent.name()).orElseThrow();\n        var oldHead = repo.head();\n        if (!repo.contains(branch, fromHash)) {\n            var isFastForward = repo.isAncestor(oldHead, fromHash);\n            repo.merge(fromHash);\n            if (!isFastForward) {\n                log.info(\"Merged \" + parent + \" into \" + branch);\n                repo.commit(\"Automatic merge with \" + parent, \"duke\", \"duke@openjdk.org\");\n            } else {\n                log.info(\"Fast forwarded \" + branch + \" to \" + parent);\n            }\n            try (var commits = repo.commits(\"origin/\" + branch.name() + \"..\" + branch.name()).stream()) {\n                log.info(\"merge with \" + parent + \" succeeded. The following commits will be pushed:\\n\"\n                        + commits\n                            .map(Commit::toString)\n                            .collect(Collectors.joining(\"\\n\", \"\\n\", \"\\n\")));\n            }\n            try {\n                repo.push(repo.head(), hostedRepo.authenticatedUrl(), branch.name());\n            } catch (IOException e) {\n                log.severe(\"Pushing failed! Aborting...\");\n                repo.reset(oldHead, true);\n                throw e;\n            }\n        }\n    }\n\n    @Override\n    public String toString() {\n        return \"TopologicalBot@\" + hostedRepo.name();\n    }\n\n    @Override\n    public List<WorkItem> getPeriodicItems() {\n        return List.of(this);\n    }\n\n    @Override\n    public String workItemName() {\n        return botName();\n    }\n\n    @Override\n    public String botName() {\n        return name();\n    }\n\n    @Override\n    public String name() {\n        return TopologicalBotFactory.NAME;\n    }\n\n    public List<Branch> getBranches() {\n        return branches;\n    }\n}\n"
  },
  {
    "path": "bots/topological/src/main/java/org/openjdk/skara/bots/topological/TopologicalBotFactory.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.topological;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.json.JSONValue;\nimport org.openjdk.skara.vcs.Branch;\n\nimport java.io.*;\nimport java.nio.file.Files;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\npublic class TopologicalBotFactory implements BotFactory {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.bots\");\n\n    static final String NAME = \"topological\";\n    @Override\n    public String name() {\n        return NAME;\n    }\n\n    @Override\n    public List<Bot> create(BotConfiguration configuration) {\n        var storage = configuration.storageFolder();\n        try {\n            Files.createDirectories(storage);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        var specific = configuration.specific();\n\n        var repoName = specific.get(\"repo\").asString();\n        var repo = configuration.repository(repoName);\n\n        var branches = specific.get(\"branches\").asArray().stream()\n                .map(JSONValue::asString)\n                .map(Branch::new)\n                .collect(Collectors.toList());\n\n        var depsFile = specific.get(\"depsFile\").asString();\n\n        log.info(\"Setting up topological merging in: \" + repoName);\n        return List.of(new TopologicalBot(storage, repo, branches, depsFile));\n    }\n}\n"
  },
  {
    "path": "bots/topological/src/main/java/org/openjdk/skara/bots/topological/TopologicalSort.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.topological;\n\nimport org.openjdk.skara.vcs.Branch;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\nclass TopologicalSort {\n    static List<Branch> sort(List<Edge> edges) {\n        List<Edge> eCopy = new ArrayList<>(edges);\n        List<Branch> result = new ArrayList<>();\n        while (!eCopy.isEmpty()) {\n            Set<Branch> orphans = eCopy.stream()\n                    .map(e -> e.from)\n                    .filter(f -> eCopy.stream().map(e -> e.to).noneMatch(f::equals))\n                    .collect(Collectors.toSet());\n            if (orphans.isEmpty()) {\n                throw new IllegalStateException(\"Detected a cycle! \" + edges);\n            }\n            orphans.forEach(o -> {\n                result.add(o);\n                eCopy.removeIf(e -> o.equals(e.from));\n            });\n        }\n\n        // add all leaves\n        edges.stream()\n            .map(e -> e.to)\n            .filter(f -> edges.stream().map(e -> e.from).noneMatch(f::equals))\n            .forEach(result::add);\n\n        return result;\n    }\n}\n"
  },
  {
    "path": "bots/topological/src/test/java/org/openjdk/skara/bots/topological/TopologicalBotFactoryTest.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.topological;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.json.JWCC;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestBotFactory;\nimport org.openjdk.skara.test.TestHostedRepository;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass TopologicalBotFactoryTest {\n    @Test\n    public void testCreate() {\n        try (var tempFolder = new TemporaryDirectory()) {\n            String jsonString = \"\"\"\n                    {\n                      \"repo\": \"repo1\",\n                      \"branches\": [\n                        \"master\",\n                        \"dev\",\n                        \"test\"\n                      ],\n                      \"depsFile\": \"test\"\n                    }\n                    \"\"\";\n            var jsonConfig = JWCC.parse(jsonString).asObject();\n\n            var testBotFactory = TestBotFactory.newBuilder()\n                    .addHostedRepository(\"repo1\", new TestHostedRepository(\"repo1\"))\n                    .storagePath(tempFolder.path().resolve(\"storage\"))\n                    .build();\n\n            var bots = testBotFactory.createBots(TopologicalBotFactory.NAME, jsonConfig);\n            // A topologicalBot for every configured repo\n            assertEquals(1, bots.size());\n\n            TopologicalBot topologicalBot1 = (TopologicalBot) bots.get(0);\n            assertEquals(\"TopologicalBot@repo1\", topologicalBot1.toString());\n            assertEquals(\"master\", topologicalBot1.getBranches().get(0).toString());\n            assertEquals(\"dev\", topologicalBot1.getBranches().get(1).toString());\n            assertEquals(\"test\", topologicalBot1.getBranches().get(2).toString());\n        }\n    }\n}"
  },
  {
    "path": "bots/topological/src/test/java/org/openjdk/skara/bots/topological/TopologicalBotTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.topological;\n\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.*;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.nio.file.Files;\nimport java.nio.file.StandardOpenOption;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\nimport static java.nio.file.StandardOpenOption.APPEND;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass TopologicalBotTests {\n\n    @Test\n    void testTopoMerge() throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var repo = TestableRepository.init(fromDir, VCS.GIT);\n            var gitConfig = repo.root().resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"),\n                        StandardOpenOption.APPEND);\n            var hostedRepo = new TestHostedRepository(host, \"test\", repo);\n\n            // make non bare\n            var readme = fromDir.resolve(\"README.txt\");\n            Files.writeString(readme, \"Hello world\\n\");\n            repo.add(readme);\n            repo.commit(\"An initial commit\", \"duke\", \"duke@openjdk.org\");\n            repo.pushAll(hostedRepo.authenticatedUrl());\n\n            var aBranch = repo.branch(repo.head(), \"A\");\n            // no deps -> depends on master\n\n            var depsFileName = \"deps.txt\";\n\n            var bBranch = repo.branch(repo.head(), \"B\");\n            repo.checkout(bBranch);\n            var bDeps = fromDir.resolve(depsFileName);\n            Files.writeString(bDeps, \"A\");\n            repo.add(bDeps);\n            repo.commit(\"Adding deps file to B\", \"duke\", \"duke@openjdk.org\");\n            repo.pushAll(hostedRepo.authenticatedUrl());\n\n            var cBranch = repo.branch(repo.head(), \"C\");\n            repo.checkout(cBranch);\n            var cDeps = fromDir.resolve(depsFileName);\n            Files.writeString(cDeps, \"B A\");\n            repo.add(cDeps);\n            repo.commit(\"Adding deps file to C\", \"duke\", \"duke@openjdk.org\");\n            repo.pushAll(hostedRepo.authenticatedUrl());\n\n            repo.checkout(new Branch(\"master\"));\n            var newFile = fromDir.resolve(\"NewFile.txt\");\n            Files.writeString(newFile, \"Hello world\\n\");\n            repo.add(newFile);\n            var preHash = repo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            repo.pushAll(hostedRepo.authenticatedUrl());\n\n            var preCommits = repo.commits().asList();\n            assertEquals(4, preCommits.size());\n            assertEquals(preHash, repo.head());\n\n            var branches = List.of(\"C\", \"A\", \"B\").stream().map(Branch::new).collect(Collectors.toList());\n            var storage = temp.path().resolve(\"storage\");\n            var bot = new TopologicalBot(storage, hostedRepo, branches, depsFileName);\n            TestBotRunner.runPeriodicItems(bot);\n\n            var postCommits = repo.commits().asList();\n            assertEquals(7, postCommits.size());\n\n            repo.checkout(aBranch);\n            assertEquals(preHash, repo.head());\n\n            repo.checkout(bBranch);\n            assertNotEquals(preHash, repo.head()); // merge commit\n\n            repo.checkout(cBranch);\n            assertNotEquals(preHash, repo.head()); // merge commit\n        }\n    }\n\n    @Test\n    void testTopoMergeFailure() throws IOException {\n        try (var temp = new TemporaryDirectory()) {\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n\n            var fromDir = temp.path().resolve(\"from.git\");\n            var repo = TestableRepository.init(fromDir, VCS.GIT);\n            var gitConfig = repo.root().resolve(\".git\").resolve(\"config\");\n            Files.write(gitConfig, List.of(\"[receive]\", \"denyCurrentBranch = ignore\"), APPEND);\n            var hostedRepo = new TestHostedRepository(host, \"test\", repo);\n\n            // make non bare\n            var readme = fromDir.resolve(\"README.txt\");\n            Files.writeString(readme, \"Hello world\\n\");\n            repo.add(readme);\n            repo.commit(\"An initial commit\", \"duke\", \"duke@openjdk.org\");\n            repo.pushAll(hostedRepo.authenticatedUrl());\n\n            var aBranch = repo.branch(repo.head(), \"A\");\n            repo.checkout(aBranch);\n            Files.writeString(readme, \"A conflicting line\\n\", APPEND);\n            repo.add(readme);\n            var aStartHash = repo.commit(\"A conflicting commit\", \"duke\", \"duke@openjdk.org\");\n            repo.pushAll(hostedRepo.authenticatedUrl());\n\n            var depsFileName = \"deps.txt\";\n\n            var bBranch = repo.branch(repo.head(), \"B\");\n            repo.checkout(bBranch);\n            var bDeps = fromDir.resolve(depsFileName);\n            Files.writeString(bDeps, \"A\");\n            repo.add(bDeps);\n            var bDepsHash = repo.commit(\"Adding deps file to B\", \"duke\", \"duke@openjdk.org\");\n            repo.pushAll(hostedRepo.authenticatedUrl());\n\n            var cBranch = repo.branch(repo.head(), \"C\");\n            repo.checkout(cBranch);\n            var cDeps = fromDir.resolve(depsFileName);\n            Files.writeString(cDeps, \"B\");\n            repo.add(cDeps);\n            var cDepsHash = repo.commit(\"Adding deps file to C\", \"duke\", \"duke@openjdk.org\");\n            repo.pushAll(hostedRepo.authenticatedUrl());\n\n            repo.checkout(new Branch(\"master\"));\n            Files.writeString(readme, \"Goodbye world!\\n\", APPEND);\n            repo.add(readme);\n            var preHash = repo.commit(\"An additional commit\", \"duke\", \"duke@openjdk.org\");\n            repo.pushAll(hostedRepo.authenticatedUrl());\n\n            var preCommits = repo.commits().asList();\n            assertEquals(5, preCommits.size());\n            assertEquals(preHash, repo.head());\n\n            var branches = List.of(\"C\", \"A\", \"B\").stream().map(Branch::new).collect(Collectors.toList());\n            var storage = temp.path().resolve(\"storage\");\n            var bot = new TopologicalBot(storage, hostedRepo, branches, depsFileName);\n            assertThrows(UncheckedIOException.class, () -> TestBotRunner.runPeriodicItems(bot));\n\n            var postCommits = repo.commits().asList();\n            assertEquals(5, postCommits.size());\n\n            repo.checkout(aBranch);\n            assertEquals(aStartHash, repo.head());\n\n            repo.checkout(bBranch);\n            assertEquals(bDepsHash, repo.head());\n\n            repo.checkout(cBranch);\n            assertEquals(cDepsHash, repo.head());\n        }\n    }\n}\n"
  },
  {
    "path": "bots/topological/src/test/java/org/openjdk/skara/bots/topological/TopologicalSortTest.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.bots.topological;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtensionContext;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.ArgumentsProvider;\nimport org.junit.jupiter.params.provider.ArgumentsSource;\nimport org.openjdk.skara.vcs.Branch;\n\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nclass TopologicalSortTest {\n\n    private static Edge edge(String from, String to) {\n        return new Edge(new Branch(from), new Branch(to));\n    }\n\n    private static List<Branch> brancheList(String... names) {\n        return Arrays.stream(names).map(Branch::new).collect(Collectors.toList());\n    }\n\n    @Test\n    void testEmpty() {\n        var branches = TopologicalSort.sort(List.of());\n        assertEquals(brancheList(), branches);\n    }\n\n    @Test\n    void testTrivial() {\n        var branches = TopologicalSort.sort(List.of(edge(\"A\", \"B\")));\n        assertEquals(brancheList(\"A\", \"B\"), branches);\n    }\n\n    @Test()\n    void testCycleTrivial() {\n        assertThrows(IllegalStateException.class, () -> TopologicalSort.sort(List.of(edge(\"A\", \"A\"))));\n    }\n\n    @Test()\n    void testCycle() {\n        assertThrows(IllegalStateException.class, () ->\n                TopologicalSort.sort(List.of(edge(\"B\", \"C\"), edge(\"A\", \"B\"), edge(\"C\", \"A\"))));\n    }\n\n    @ParameterizedTest\n    @ArgumentsSource(EdgeProvider.class)\n    void testSort(List<Edge> edges) {\n        var branches = TopologicalSort.sort(edges);\n        assertEquals(brancheList(\"A\", \"B\", \"C\", \"D\", \"E\"), branches);\n    }\n\n    private static class EdgeProvider implements ArgumentsProvider {\n        @Override\n        public Stream<? extends Arguments> provideArguments(ExtensionContext context) throws Exception {\n            List<Edge> edges = List.of(edge(\"A\", \"B\"), edge(\"B\", \"C\"), edge(\"C\", \"D\"), edge(\"B\", \"D\"), edge(\"D\", \"E\"));\n            List<List<Edge>> permutations = new ArrayList<>();\n            permutations(edges, List.of(), permutations);\n            return permutations.stream().map(Arguments::arguments);\n        }\n\n        static void permutations(List<Edge> source, List<Edge> perm, List<List<Edge>> result) {\n            if (source.size() == perm.size()) {\n                result.add(perm);\n                return;\n            }\n            for (var edge : source) {\n                if (!perm.contains(edge)) {\n                    List<Edge> newPerm = new ArrayList<>(perm);\n                    newPerm.add(edge);\n                    permutations(source, newPerm, result);\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "bots.dockerfile",
    "content": "# Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n#\n# This code is free software; you can redistribute it and/or modify it\n# under the terms of the GNU General Public License version 2 only, as\n# published by the Free Software Foundation.\n#\n# This code is distributed in the hope that it will be useful, but WITHOUT\n# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n# version 2 for more details (a copy is included in the LICENSE file that\n# accompanied this code).\n#\n# You should have received a copy of the GNU General Public License version\n# 2 along with this work; if not, write to the Free Software Foundation,\n# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n#\n# Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n# or visit www.oracle.com if you need additional information or have any\n# questions.\n\nFROM oraclelinux:7.5 as prerequisites-runtime\n\nWORKDIR /bots-build\n\nARG GIT_VERSION=2.19.3\nARG MERCURIAL_VERSION=4.7.2\n\nENV LANG en_US.UTF-8\nENV LANGUAGE en_US:en\nENV LC_ALL en_US.UTF-8\n\nRUN yum -y install make autoconf gcc curl-devel expat-devel gettext-devel openssl-devel perl-devel zlib-devel python-devel\nRUN curl -sSO https://www.mercurial-scm.org/release/mercurial-${MERCURIAL_VERSION}.tar.gz && \\\n    echo \"97f0594216f2348a2e37b2ad8a56eade044e741153fee8c584487e9934ca09fb  mercurial-4.7.2.tar.gz\" | sha256sum --check - && \\\n    tar xvfz mercurial-${MERCURIAL_VERSION}.tar.gz && \\\n    cd mercurial-${MERCURIAL_VERSION} && \\\n    python setup.py install --force --prefix=/bots/hg\nRUN curl -sSO https://mirrors.edge.kernel.org/pub/software/scm/git/git-${GIT_VERSION}.tar.xz && \\\n    echo \"0457f33eedd3f5e9fb9c2ea30bf455ed9915230e3800c632ff07e00ac2466ace git-${GIT_VERSION}.tar.xz\" | sha256sum --check - && \\\n    tar xvfJ git-${GIT_VERSION}.tar.xz && \\\n    cd git-${GIT_VERSION} && \\\n    make configure && \\\n    ./configure --prefix=/bots/git && \\\n    make all && \\\n    make install\n\n\nFROM oraclelinux:7.5 as prerequisites-compiletime\n\nWORKDIR /bots-build\n\nARG JAVA_OPTIONS\nARG GRADLE_OPTIONS\n\nENV LANG en_US.UTF-8\nENV LANGUAGE en_US:en\nENV LC_ALL en_US.UTF-8\n\nRUN yum -y install unzip\n\nCOPY gradlew ./\nCOPY deps.env ./\n\nENV JAVA_TOOL_OPTIONS=$JAVA_OPTIONS\nRUN sh gradlew --no-daemon --version $GRADLE_OPTIONS\n\n\nFROM oraclelinux:7.5 as builder\n\nWORKDIR /bots-build\n\nARG JAVA_OPTIONS\nARG GRADLE_OPTIONS\n\nENV LANG en_US.UTF-8\nENV LANGUAGE en_US:en\nENV LC_ALL en_US.UTF-8\n\nRUN yum -y install rsync\n\nCOPY --from=prerequisites-compiletime /bots-build ./\nCOPY --from=prerequisites-runtime /bots/git/ /bots/git/\nCOPY --from=prerequisites-runtime /bots/hg/ /bots/hg/\nCOPY ./ ./\n\nENV JAVA_TOOL_OPTIONS=$JAVA_OPTIONS\nENV PATH=/bots/git/bin:/bots/hg/bin:${PATH}\nRUN sh gradlew --no-daemon $GRADLE_OPTIONS :bots:cli:images\n\n\nFROM oraclelinux:7.5\n\nWORKDIR /bots\n\nLABEL org.openjdk.bots=true\n\nARG JAVA_OPTIONS\n\nENV LANG en_US.UTF-8\nENV LANGUAGE en_US:en\nENV LC_ALL en_US.UTF-8\n\nRUN yum -y install rsync unzip && yum clean all\n\nCOPY --from=prerequisites-runtime /bots/git/ /bots/git/\nCOPY --from=prerequisites-runtime /bots/hg/ /bots/hg/\nCOPY --from=builder /bots-build/bots/cli/build/distributions/cli-unknown-linux-x64.tar.gz /bots/tar/\n\nENV JAVA_TOOL_OPTIONS=$JAVA_OPTIONS\nENV PATH=/bots/git/bin:/bots/hg/bin:${PATH}\n\nRUN tar xvf /bots/tar/cli-unknown-linux-x64.tar.gz\n\nENTRYPOINT [\"/bots/bin/skara-bots\"]\nCMD [\"--help\"]\n"
  },
  {
    "path": "build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nbuildscript {\n    dependencies {\n        classpath 'org.openjdk.skara.gradle:skara-reproduce'\n        classpath 'org.openjdk.skara.gradle:skara-proxy'\n        classpath 'org.openjdk.skara.gradle:skara-version'\n        classpath 'org.openjdk.skara.gradle:skara-images'\n        classpath 'org.openjdk.skara.gradle:skara-module'\n    }\n}\n\nplugins {\n    id 'skara-proxy'\n    id 'skara-version'\n    id 'skara-reproduce'\n}\n\nconfigure(subprojects.findAll() { it.name != 'bots' }) {\n    apply plugin: 'java-library'\n    apply plugin: 'maven-publish'\n    apply plugin: 'skara-module'\n    apply plugin: 'skara-version'\n\n    group = 'org.openjdk.skara'\n\n    repositories {\n        mavenLocal()\n        maven {\n            url System.getProperty('maven.url', 'https://repo.maven.apache.org/maven2/')\n        }\n    }\n\n    dependencies {\n        testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'\n        testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2'\n        testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'\n        // Force Gradle to load the JUnit Platform Launcher from the module-path, as\n        // configured in buildSrc/.../ModulePlugin.java -- see SKARA-69 for details.\n        testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.8.2'\n    }\n\n    tasks.withType(JavaCompile).configureEach {\n        options.release.set(21)\n    }\n\n    compileJava.options.encoding = 'UTF-8'\n    compileTestJava.options.encoding = 'UTF-8'\n\n    test {\n        useJUnitPlatform()\n\n        if (findProperty('credentials')) {\n            systemProperty \"credentials\", findProperty('credentials')\n        }\n\n        testLogging {\n            events \"passed\", \"skipped\", \"failed\"\n            exceptionFormat \"full\"\n        }\n\n        reports.html.required = false\n    }\n\n    tasks.withType(Test).configureEach {\n        maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1\n    }\n\n    tasks.withType(Test).configureEach {\n        forkEvery = 100\n    }\n\n    tasks.withType(Test).configureEach {\n        reports.html.required = false\n        reports.junitXml.required = false\n    }\n\n    tasks.withType(JavaCompile).configureEach {\n        options.fork = true\n    }\n\n    publishing {\n        repositories {\n            maven {\n                url = findProperty('mavenRepositoryUrl')\n                credentials {\n                    username = findProperty('mavenRepositoryUser')\n                    password = findProperty('mavenRepositoryPassword')\n                }\n            }\n        }\n    }\n\n    gradle.taskGraph.whenReady { graph ->\n        if (graph.hasTask(publish) && !findProperty('mavenRepositoryUrl')) {\n            throw new GradleException(\"To publish artifacts, set the maven repository url -PmavenRepositoryUrl=<url>\")\n        }\n        if (graph.hasTask(publish) && !findProperty('mavenRepositoryUser')) {\n            throw new GradleException(\"To publish artifacts, set the maven repository user name -PmavenRepositoryUser=<user>\")\n        }\n        if (graph.hasTask(publish) && !findProperty('mavenRepositoryPassword')) {\n            throw new GradleException(\"To publish artifacts, set the maven repository password -PmavenRepositoryPassword=<password>\")\n        }\n    }\n}\n\ntask test {\n    subprojects.findAll() { !it.getTasksByName('test', false).isEmpty() }.each { dependsOn \"${it.path}:test\" }\n}\n\ntask testReport(type: TestReport) {\n    destinationDirectory = file(\"$buildDir/reports/allTests\")\n    getTestResults().from(subprojects.findAll()*.getTasksByName('test', false))\n}\n\ntask clean {\n    subprojects.findAll() { !it.getTasksByName('clean', false).isEmpty() }.each { dependsOn \"${it.path}:clean\" }\n}\n\nreproduce {\n    dockerfile = 'test.dockerfile'\n}\n\ndef getOS() {\n    def os = System.getProperty('os.name').toLowerCase()\n    if (os.startsWith('linux')) {\n        return 'linux'\n    }\n    if (os.startsWith('mac')) {\n        return 'macos'\n    }\n    if (os.startsWith('win')) {\n        return 'windows'\n    }\n    if (os.startsWith('sunos')) {\n        return 'solaris'\n    }\n    throw new GradleException(\"Unexpected operating system: \" + os)\n}\n\ndef getCPU() {\n    def cpu = System.getProperty('os.arch').toLowerCase()\n    if (cpu.startsWith('amd64') || cpu.startsWith('x86_64') || cpu.startsWith('x64')) {\n        return 'x64'\n    }\n    if (cpu.startsWith('x86') || cpu.startsWith('i386')) {\n        return 'x86'\n    }\n    if (cpu.startsWith('sparc')) {\n        return 'sparc'\n    }\n    if (cpu.startsWith('ppc')) {\n        return 'ppc'\n    }\n    if (cpu.startsWith('arm')) {\n        return 'arm'\n    }\n    if (cpu.startsWith('aarch64')) {\n        return 'aarch64';\n    }\n    throw new GradleException(\"Unexpected CPU: \" + cpu)\n}\n\ntask local(type: Copy) {\n    doFirst {\n        delete project.buildDir.toString() + '/cli'\n    }\n\n    def os = getOS()\n    def cpu = getCPU()\n\n    if (os in ['linux', 'macos', 'windows'] && cpu == 'x64') {\n        def target = os.substring(0, 1).toUpperCase() + os.substring(1) +\n                     cpu.substring(0, 1).toUpperCase() + cpu.substring(1)\n        dependsOn ':cli:image' + target\n    } else {\n        dependsOn ':cli:imageLocal'\n    }\n\n    from zipTree(file(project.rootDir.toString() +\n                      '/cli/build/distributions/cli' +\n                      '-' + project.version + '-' +\n                      os + '-' + cpu + '.zip'))\n    into project.buildDir.toString() + '/cli'\n}\n\ntask bots(type: Copy) {\n    doFirst {\n        delete project.rootDir.toString() + '/bots/bin'\n    }\n\n    dependsOn ':bots:cli:images'\n\n    from zipTree(file(project.rootDir.toString() +\n                      '/bots/cli/build/distributions/cli' +\n                      '-' + project.version +\n                      '-linux-x64.zip'))\n    into project.rootDir.toString() + '/bots/bin'\n}\n\ntask offline(type: Copy) {\n    doFirst {\n        delete project.buildDir\n    }\n\n    def os = getOS()\n    def cpu = getCPU()\n\n    dependsOn ':cli:imageLocal'\n    from zipTree(file(project.rootDir.toString() +\n                      '/cli/build/distributions/cli' +\n                      '-' + project.version + '-' +\n                      os + '-' + cpu + '.zip'))\n    into project.buildDir\n}\n\ndefaultTasks 'local'\n"
  },
  {
    "path": "census/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.census'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        requires 'org.openjdk.skara.vcs'\n        opens 'org.openjdk.skara.census' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':xml')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':host')\n\n    testImplementation project(':test')\n    testImplementation project(':vcs')\n}\n\npublishing {\n    publications {\n        census(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "census/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.census {\n    requires org.openjdk.skara.xml;\n    requires static org.openjdk.skara.forge;\n    requires java.xml;\n    requires java.net.http;\n    requires java.logging;\n    exports org.openjdk.skara.census;\n}\n"
  },
  {
    "path": "census/src/main/java/org/openjdk/skara/census/Census.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.census;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.net.URI;\nimport java.net.http.*;\nimport java.time.*;\n\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.xml.XML;\nimport org.w3c.dom.Document;\nimport static java.util.function.Function.identity;\nimport static java.util.stream.Collectors.toMap;\nimport static java.net.http.HttpResponse.BodyHandlers;\n\npublic class Census {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.census\");\n    private final Map<String, Contributor> contributors;\n    private final Map<String, Group> groups;\n    private final Map<String, Project> projects;\n    private final Map<String, Namespace> namespaces;\n    private final Version version;\n\n    Census(Map<String, Contributor> contributors, Map<String, Group> groups, List<Project> projects, List<Namespace> namespaces, Version version) {\n        this.contributors = contributors;\n        this.groups = groups;\n        this.projects = projects.stream().collect(toMap(Project::name, identity()));\n        this.namespaces = namespaces.stream().collect(toMap(Namespace::name, identity()));\n        this.version = version;\n    }\n\n    public static Census empty() {\n        return new Census(Map.of(), Map.of(), List.of(), List.of(), null);\n    }\n\n    public List<Contributor> contributors() {\n        return List.copyOf(contributors.values());\n    }\n\n    public List<Group> groups() {\n        return List.copyOf(groups.values());\n    }\n\n    public List<Project> projects() {\n        return List.copyOf(projects.values());\n    }\n\n    public List<Namespace> namespaces() {\n        return List.copyOf(namespaces.values());\n    }\n\n    public Contributor contributor(String name) {\n        return contributors.get(name);\n    }\n\n    public boolean isContributor(String name) {\n        return contributors.containsKey(name);\n    }\n\n    public Group group(String name) {\n        return groups.get(name);\n    }\n\n    public boolean isGroup(String name) {\n        return groups.containsKey(name);\n    }\n\n    public Project project(String name) {\n        return projects.get(name);\n    }\n\n    public boolean isProject(String name) {\n        return projects.containsKey(name);\n    }\n\n    public Namespace namespace(String name) {\n        return namespaces.get(name);\n    }\n\n    public boolean isNamespace(String name) {\n        return namespaces.containsKey(name);\n    }\n\n    public Version version() {\n        return version;\n    }\n\n    private static List<Path> xmlFiles(Path dir) throws IOException {\n        var files = new ArrayList<Path>();\n\n        if (Files.isDirectory(dir)) {\n            try (var stream = Files.newDirectoryStream(dir, \"*.xml\")) {\n                for (var xmlFile : stream) {\n                    files.add(xmlFile);\n                }\n            }\n        }\n\n        return files;\n    }\n\n    private static Census parseDirectory(Path p) throws IOException {\n        log.finer(\"Parsing directory \" + p.toString());\n        var contributorsFile = p.resolve(\"contributors.xml\");\n        var contributors = Files.exists(contributorsFile) ?\n            Contributors.parse(contributorsFile) : new HashMap<String, Contributor>();\n\n        var groups = new ArrayList<Group>();\n        for (var file : xmlFiles(p.resolve(\"groups\"))) {\n            groups.add(Group.parse(file, contributors));\n        }\n        var groupMap = groups.stream().collect(toMap(Group::name, identity()));\n\n        var projects = new ArrayList<Project>();\n        for (var file : xmlFiles(p.resolve(\"projects\"))) {\n            projects.add(Project.parse(file, groupMap, contributors));\n        }\n\n        var namespaces = new ArrayList<Namespace>();\n        for (var file : xmlFiles(p.resolve(\"namespaces\"))) {\n            namespaces.add(Namespace.parse(file, contributors));\n        }\n\n        var version = Version.parse(p.resolve(\"version.xml\"));\n\n        return new Census(contributors, groupMap, projects, namespaces, version);\n    }\n\n    private static Census parseDocument(Document document) throws IOException {\n        var census = XML.child(document, \"census\");\n\n        var date = ZonedDateTime.parse(XML.attribute(census, \"time\"));\n        var timestamp = date.toInstant();\n        var version = new Version(0, timestamp);\n\n        var contributors = XML.children(census, \"person\")\n                              .stream()\n                              .map(e -> new Contributor(XML.attribute(e, \"name\"),\n                                                        XML.child(e, \"full-name\").getTextContent()))\n                              .collect(toMap(Contributor::username, identity()));\n\n        var groups = new HashMap<String, Group>();\n        for (var ele : XML.children(census, \"group\")) {\n            var group = Group.parse(ele, contributors);\n            groups.put(group.name(), group);\n        }\n\n        var projects = new ArrayList<Project>();\n        for (var ele : XML.children(census, \"project\")) {\n            projects.add(Project.parse(ele, groups, contributors));\n        }\n\n        var namespaces = new ArrayList<Namespace>();\n        for (var ele : XML.children(census, \"namespace\")) {\n            namespaces.add(Namespace.parse(ele, contributors));\n        }\n\n        return new Census(contributors, groups, projects, namespaces, version);\n    }\n\n    private static Census parseSingleFile(Path p) throws IOException {\n        log.finer(\"Parsing single file \" + p.toString());\n        return parseDocument(XML.parse(p));\n    }\n\n    public static Census parse(List<String> lines) throws IOException {\n        return parseDocument(XML.parse(lines));\n    }\n\n    public static Census parse(Path p) throws IOException {\n        return Files.isDirectory(p) ? parseDirectory(p) : parseSingleFile(p);\n    }\n\n    private static Path download(URI uri) throws IOException, InterruptedException {\n        log.finer(\"Downloading census from \" + uri.toString());\n        var tmpFile = Files.createTempFile(\"census\", \".xml\");\n        var client = HttpClient.newHttpClient();\n        var request = HttpRequest.newBuilder()\n                                 .uri(uri)\n                                 .build();\n        var response = client.send(request, BodyHandlers.ofFile(tmpFile));\n        return tmpFile;\n    }\n\n    public static Census from(URI uri) throws IOException {\n        try {\n            return parse(download(uri));\n        } catch (InterruptedException e) {\n            throw new IOException(e);\n        }\n    }\n\n    /**\n     * Initializes a single Namespace directly from a hosted repository. This works\n     * because the files needed to populate a single namespace are statically known.\n     * A full Census needs to discover files by listing them, which makes\n     * initialization from a remote repository inconvenient.\n     *\n     * @param repository HostedRepository to initialize from\n     * @param ref The reference in the repository to get data from\n     * @param name Name of namespace to initialize\n     * @return Just the named Namespace from the Census hosted in the repository\n     */\n    public static Namespace parseNamespace(HostedRepository repository, String ref, String name) throws IOException {\n        log.finer(\"Parsing namespace from repository \" + repository.name());\n        var contributorsData = repository.fileContents(\"contributors.xml\", ref)\n                .orElseThrow(() -> new RuntimeException(\"Could not find contributors.xml on ref \" + ref + \" in repo \" + repository.name()));\n        var contributors = Contributors.parse(contributorsData);\n        var namespaceData = repository.fileContents(\"namespaces/\" + name + \".xml\", ref)\n                .orElseThrow(() -> new RuntimeException(\"Could not find namespaces/\" + name + \".xml on ref \" + ref + \" in repo \" + repository.name()));\n        return Namespace.parse(namespaceData, contributors);\n    }\n}\n"
  },
  {
    "path": "census/src/main/java/org/openjdk/skara/census/Contributor.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.census;\n\nimport java.util.Objects;\nimport java.util.Optional;\n\npublic class Contributor {\n    private final String username;\n    private final String fullName;\n\n    Contributor(String username) {\n        this.username = username;\n        this.fullName = null;\n    }\n\n    Contributor(String username, String fullName) {\n        this.username = username;\n        this.fullName = fullName;\n    }\n\n    public String username() {\n        return username;\n    }\n\n    public Optional<String> fullName() {\n        return Optional.ofNullable(fullName);\n    }\n\n    @Override\n    public String toString() {\n        if (fullName != null) {\n            return username + \" (\" + fullName + \")\";\n        }\n        return username;\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(username, fullName);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (o instanceof Contributor other) {\n            return Objects.equals(username, other.username) &&\n                   Objects.equals(fullName, other.fullName);\n        }\n        return false;\n    }\n}\n"
  },
  {
    "path": "census/src/main/java/org/openjdk/skara/census/Contributors.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.census;\n\nimport org.openjdk.skara.xml.XML;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.*;\nimport org.w3c.dom.Document;\n\nimport static java.util.function.Function.identity;\nimport static java.util.stream.Collectors.toMap;\n\nclass Contributors {\n    static Map<String, Contributor> parse(Path p) throws IOException {\n        return parse(XML.parse(p));\n    }\n\n    static Map<String, Contributor> parse(String s) throws IOException {\n        return parse(XML.parse(s));\n    }\n\n    private static Map<String, Contributor> parse(Document document) {\n        var result = new ArrayList<Contributor>();\n        var contributors = XML.child(document, \"contributors\");\n        for (var contributor : XML.children(contributors, \"contributor\")) {\n            var username = XML.attribute(contributor, \"username\");\n            var fullName = XML.attribute(contributor, \"full-name\");\n\n            result.add(new Contributor(username, fullName));\n        }\n\n        return result.stream().collect(toMap(Contributor::username, identity()));\n    }\n}\n"
  },
  {
    "path": "census/src/main/java/org/openjdk/skara/census/Group.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.census;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.util.*;\n\nimport org.openjdk.skara.xml.XML;\nimport org.w3c.dom.*;\n\npublic class Group {\n    private final String name;\n    private final String fullName;\n    private final Contributor lead;\n    private final Map<String, Contributor> members = new HashMap<>();\n\n    Group(String name, String fullName, Contributor lead, List<Contributor> members) {\n        this.name = name;\n        this.fullName = fullName;\n        this.lead = lead;\n\n        for (var member : members) {\n            this.members.put(member.username(), member);\n        }\n        this.members.put(lead.username(), lead);\n    }\n\n    public Set<Contributor> members() {\n        var result = new HashSet<Contributor>();\n        for (var username : members.keySet()) {\n            result.add(members.get(username));\n        }\n        return result;\n    }\n\n    public String name() {\n        return name;\n    }\n\n    public String fullName() {\n        return fullName;\n    }\n\n    public Contributor lead() {\n        return lead;\n    }\n\n    public boolean contains(String username) {\n        return members.containsKey(username);\n    }\n\n    static Group parse(Path file, Map<String, Contributor> contributors) throws IOException {\n        var document = XML.parse(file);\n        var group = XML.child(document, \"group\");\n        var name = XML.attribute(group, \"name\");\n        var fullName = XML.attribute(group, \"full-name\");\n\n        var leadUsername = XML.attribute(XML.child(group, \"lead\"), \"username\");\n        if (!contributors.containsKey(leadUsername)) {\n            contributors.put(leadUsername, new Contributor(leadUsername));\n        }\n        var lead = contributors.get(leadUsername);\n\n        var members = new ArrayList<Contributor>();\n        for (var member : XML.children(group, \"member\")) {\n            var username = XML.attribute(member, \"username\");\n            if (!contributors.containsKey(username)) {\n                contributors.put(username, new Contributor(username));\n            }\n            members.add(contributors.get(username));\n        }\n\n        return new Group(name, fullName, lead, members);\n    }\n\n    static Group parse(Element ele, Map<String, Contributor> contributors) throws IOException {\n        var name = XML.attribute(ele, \"name\");\n        var fullName = XML.child(ele, \"full-name\").getTextContent();\n\n        Contributor lead = null;\n        var members = new ArrayList<Contributor>();\n        for (var person : XML.children(ele, \"person\")) {\n            var username = XML.attribute(person, \"ref\");\n            if (!contributors.containsKey(username)) {\n                contributors.put(username, new Contributor(username));\n            }\n\n            if (XML.hasAttribute(person, \"role\")) {\n                if (!XML.attribute(person, \"role\").equals(\"lead\")) {\n                    throw new IOException(\"Unexpected role: \" + XML.attribute(person, \"role\"));\n                }\n                lead = contributors.get(username);\n            } else {\n                members.add(contributors.get(username));\n            }\n        }\n\n        return new Group(name, fullName, lead, members);\n    }\n\n    @Override\n    public String toString() {\n        return name + \" (\" + fullName + \")\";\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(name, fullName);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (o instanceof Group other) {\n            return Objects.equals(name, other.name) &&\n                   Objects.equals(fullName, other.fullName);\n        }\n        return false;\n    }\n}\n"
  },
  {
    "path": "census/src/main/java/org/openjdk/skara/census/Member.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.census;\n\nimport java.util.Objects;\nimport java.util.Optional;\n\nclass Member {\n    private final Contributor contributor;\n    private final int since;\n    private final int until;\n\n    Member(Contributor contributor) {\n        this(contributor, 0);\n    }\n\n    Member(Contributor contributor, int since) {\n        this(contributor, since, Integer.MAX_VALUE);\n    }\n\n\n    Member(Contributor contributor, int since, int until) {\n        this.contributor = contributor;\n        this.since = since;\n        this.until = until;\n    }\n\n    public String username() {\n        return contributor.username();\n    }\n\n    public Optional<String> fullName() {\n        return contributor.fullName();\n    }\n\n    public Contributor contributor() {\n        return contributor;\n    }\n\n    public int since() {\n        return since;\n    }\n\n    public int until() {\n        return until;\n    }\n\n    @Override\n    public String toString() {\n        if (until == Integer.MAX_VALUE) {\n            return String.format(\"%s [%d,)\", contributor, since);\n        }\n        return String.format(\"%s [%d,%d)\", contributor, since, until);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(contributor, since, until);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (o instanceof Member other) {\n            return contributor.equals(other.contributor()) &&\n                   since == other.since() &&\n                   until == other.until();\n        }\n        return false;\n    }\n}\n"
  },
  {
    "path": "census/src/main/java/org/openjdk/skara/census/Namespace.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.census;\n\nimport org.openjdk.skara.xml.XML;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.*;\nimport org.w3c.dom.Document;\nimport org.w3c.dom.Element;\n\npublic class Namespace {\n    private final String name;\n    private final Map<String, Contributor> mapping;\n    private final Map<Contributor, String> reverse;\n\n    private Namespace(String name, Map<String, Contributor> mapping, Map<Contributor, String> reverse) {\n        this.name = name;\n        this.mapping = mapping;\n        this.reverse = reverse;\n    }\n\n    public String name() {\n        return name;\n    }\n\n    public Contributor get(String id) {\n        return mapping.get(id);\n    }\n\n    public String get(Contributor contributor) {\n        return reverse.get(contributor);\n    }\n\n    public Set<Map.Entry<String, Contributor>> entries() {\n        return mapping.entrySet();\n    }\n\n    static Namespace parse(Path p, Map<String, Contributor> contributors) throws IOException {\n        var document = XML.parse(p);\n        return parse(document, contributors);\n    }\n\n    static Namespace parse(String s, Map<String, Contributor> contributors) throws IOException {\n        var document = XML.parse(s);\n        return parse(document, contributors);\n    }\n\n    private static Namespace parse(Document document, Map<String, Contributor> contributors) throws IOException {\n        var namespace = XML.child(document, \"namespace\");\n        return parse(namespace, contributors);\n    }\n\n    static Namespace parse(Element ele, Map<String, Contributor> contributors) throws IOException {\n        var mapping = new HashMap<String, Contributor>();\n        var reverse = new HashMap<Contributor, String>();\n        var name = XML.attribute(ele, \"name\");\n\n        for (var user : XML.children(ele, \"user\")) {\n            var id = XML.attribute(user, \"id\");\n            var to = XML.attribute(user, \"census\");\n\n            if (!contributors.containsKey(to)) {\n                throw new IllegalArgumentException(\"Unknown contributor \" + to);\n            }\n            var contributor = contributors.get(to);\n            mapping.put(id, contributor);\n            reverse.put(contributor, id);\n        }\n\n        return new Namespace(name, mapping, reverse);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        Namespace namespace = (Namespace) o;\n        return Objects.equals(name, namespace.name)\n                && Objects.equals(mapping, namespace.mapping)\n                && Objects.equals(reverse, namespace.reverse);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(name, mapping, reverse);\n    }\n}\n"
  },
  {
    "path": "census/src/main/java/org/openjdk/skara/census/Parser.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.census;\n\nimport java.util.*;\n\nimport org.openjdk.skara.xml.XML;\nimport org.w3c.dom.*;\n\nclass Parser {\n    public static List<Member> members(List<Element> elements, Map<String, Contributor> contributors) {\n        var members = new ArrayList<Member>();\n\n        for (var element : elements) {\n            var username = XML.attribute(element, \"username\");\n            if (!contributors.containsKey(username)) {\n                contributors.put(username, new Contributor(username));\n            }\n            var contributor = contributors.get(username);\n\n            var since = Integer.parseInt(XML.attribute(element, \"since\"));\n\n            if (XML.hasAttribute(element, \"until\")) {\n                var until = Integer.parseInt(XML.attribute(element, \"until\"));\n                members.add(new Member(contributor, since, until));\n            } else {\n                members.add(new Member(contributor, since));\n            }\n        }\n\n        return members;\n    }\n}\n"
  },
  {
    "path": "census/src/main/java/org/openjdk/skara/census/Project.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.census;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.util.*;\n\nimport org.openjdk.skara.xml.XML;\nimport org.w3c.dom.*;\n\npublic class Project {\n    private final String name;\n    private final String fullName;\n    private final Group sponsor;\n\n    private final Map<String, Member> leaders = new HashMap<>();\n    private final Map<String, Member> reviewers = new HashMap<>();\n    private final Map<String, Member> committers = new HashMap<>();\n    private final Map<String, Member> authors = new HashMap<>();\n\n    private void populate(Map<String, Member> category, List<Member> members) {\n        for (var member : members) {\n            category.put(member.username(), member);\n        }\n    }\n\n    Project(String name,\n            String fullName,\n            Group sponsor,\n            List<Member> leaders,\n            List<Member> reviewers,\n            List<Member> committers,\n            List<Member> authors) {\n        this.name = name;\n        this.fullName = fullName;\n        this.sponsor = sponsor;\n\n        populate(this.leaders, leaders);\n        populate(this.reviewers, reviewers);\n        populate(this.committers, committers);\n        populate(this.authors, authors);\n    }\n\n    public String name() {\n        return name;\n    }\n\n    public String fullName() {\n        return fullName;\n    }\n\n    public Group sponsor() {\n        return sponsor;\n    }\n\n    private boolean isMember(Map<String, Member> category, String username, int version) {\n        if (!category.containsKey(username)) {\n            return false;\n        }\n\n        var member = category.get(username);\n        return version >= member.since() && version < member.until();\n    }\n\n    public boolean isLead(String username, int version) {\n        return isMember(leaders, username, version);\n    }\n\n    public boolean isReviewer(String username, int version) {\n        return isLead(username, version) ||\n               isMember(reviewers, username, version);\n    }\n\n    public boolean isCommitter(String username, int version) {\n        return isReviewer(username, version) ||\n               isMember(committers, username, version);\n    }\n\n    public boolean isAuthor(String username, int version) {\n        return isCommitter(username, version) ||\n               isMember(authors, username, version);\n    }\n\n    private Set<Contributor> members(Map<String, Member> category, int version) {\n        var result = new HashSet<Contributor>();\n        for (var username : category.keySet()) {\n            if (isMember(category, username, version)) {\n                result.add(category.get(username).contributor());\n            }\n        }\n        return result;\n    }\n\n    public Map<String, Set<Contributor>> roles(int version) {\n        var res = new HashMap<String, Set<Contributor>>();\n        var lead = lead(version);\n        res.put(\"lead\", lead == null ? Set.of() : Set.of(lead));\n        res.put(\"reviewer\", members(reviewers, version));\n        res.put(\"committer\", members(committers, version));\n        res.put(\"author\", members(authors, version));\n        return res;\n    }\n\n    public Contributor lead(int version) {\n        var leadersAtVersion = members(leaders, version);\n        if (leadersAtVersion.size() != 1) {\n            return null;\n        }\n        return leadersAtVersion.iterator().next();\n    }\n\n    public Set<Contributor> reviewers(int version) {\n        var leaderAtVersion = lead(version);\n        var reviewersAtVersion = members(reviewers, version);\n        if (leaderAtVersion != null) {\n            reviewersAtVersion.add(leaderAtVersion);\n        }\n        return reviewersAtVersion;\n    }\n\n    public Set<Contributor> committers(int version) {\n        var reviewersAtVersion = reviewers(version);\n        var committersAtVersion = members(committers, version);\n        committersAtVersion.addAll(reviewersAtVersion);\n        return committersAtVersion;\n    }\n\n    public Set<Contributor> authors(int version) {\n        var committersAtVersion = committers(version);\n        var authorsAtVersion = members(authors, version);\n        authorsAtVersion.addAll(committersAtVersion);\n        return authorsAtVersion;\n    }\n\n    static Project parse(Path file, Map<String, Group> groups, Map<String, Contributor> contributors) throws IOException {\n        var document = XML.parse(file);\n        var project = XML.child(document, \"project\");\n        var name = XML.attribute(project, \"name\");\n        var fullName = XML.attribute(project, \"full-name\");\n        var sponsorName = XML.attribute(project, \"sponsor\");\n        if (!groups.containsKey(sponsorName)) {\n            throw new IllegalArgumentException(\"Unknown group \" + sponsorName);\n        }\n        var sponsor = groups.get(sponsorName);\n\n        var leaders = Parser.members(XML.children(project, \"lead\"), contributors);\n        var reviewers = Parser.members(XML.children(project, \"reviewer\"), contributors);\n        var committers = Parser.members(XML.children(project, \"committer\"), contributors);\n        var authors = Parser.members(XML.children(project, \"author\"), contributors);\n\n        return new Project(name, fullName, sponsor, leaders, reviewers, committers, authors);\n    }\n\n    static Project parse(Element ele, Map<String, Group> groups, Map<String, Contributor> contributors) throws IOException {\n        var name = XML.attribute(ele, \"name\");\n        var fullName = XML.child(ele, \"full-name\").getTextContent();\n\n        var sponsorName = XML.attribute(XML.child(ele, \"sponsor\"), \"ref\");\n        if (!groups.containsKey(sponsorName)) {\n            throw new IllegalArgumentException(\"Unknown group \" + sponsorName);\n        }\n        var sponsor = groups.get(sponsorName);\n\n        var leaders = new ArrayList<Member>();\n        var committers = new ArrayList<Member>();\n        var reviewers = new ArrayList<Member>();\n        var authors = new ArrayList<Member>();\n\n        for (var person : XML.children(ele, \"person\")) {\n            var username = XML.attribute(person, \"ref\");\n            if (!contributors.containsKey(username)) {\n                contributors.put(username, new Contributor(username));\n            }\n            var member = new Member(contributors.get(username), 0);\n\n            switch (XML.attribute(person, \"role\")) {\n                case \"lead\":\n                    leaders.add(member);\n                    break;\n                case \"reviewer\":\n                    reviewers.add(member);\n                    break;\n                case \"committer\":\n                    committers.add(member);\n                    break;\n                case \"author\":\n                    authors.add(member);\n                    break;\n                default:\n                    if ((username.equals(\"dwookey\") || username.equals(\"jpereda\")) &&\n                        name.equals(\"openjfx\")) {\n                        authors.add(member);\n                    } else {\n                        throw new IOException(\"Unexpected role for \" + username +\n                                              \" in project \" + name + \": '\" + XML.attribute(person, \"role\") + \"'\");\n                    }\n            }\n        }\n\n        return new Project(name, fullName, sponsor, leaders, reviewers, committers, authors);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(name, fullName, sponsor, leaders, reviewers, committers, authors);\n    }\n\n    @Override\n    public String toString() {\n        return name + \" (\" + fullName + \")\";\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof Project p)) {\n            return false;\n        }\n\n        return Objects.equals(name, p.name) &&\n               Objects.equals(fullName, p.fullName) &&\n               Objects.equals(sponsor, p.sponsor) &&\n               Objects.equals(leaders, p.leaders) &&\n               Objects.equals(reviewers, p.reviewers) &&\n               Objects.equals(committers, p.committers) &&\n               Objects.equals(authors, p.authors);\n    }\n}\n"
  },
  {
    "path": "census/src/main/java/org/openjdk/skara/census/Version.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.census;\n\nimport org.openjdk.skara.xml.XML;\n\nimport java.time.Instant;\nimport java.nio.file.Path;\nimport java.io.IOException;\nimport java.util.Objects;\n\npublic class Version {\n    private final int format;\n    private final Instant timestamp;\n\n    Version(int format, Instant timestamp) {\n        this.format = format;\n        this.timestamp = timestamp;\n    }\n\n    public Instant timestamp() {\n        return timestamp;\n    }\n\n    public int format() {\n        return format;\n    }\n\n    static Version parse(Path p) throws IOException {\n        var document = XML.parse(p);\n        var version = XML.child(document, \"version\");\n        var format = Integer.parseInt(XML.attribute(version, \"format\"));\n        var timestamp = Instant.parse(XML.attribute(version, \"timestamp\"));\n\n        return new Version(format, timestamp);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(format, timestamp);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof Version other)) {\n            return false;\n        }\n\n        return Objects.equals(format, other.format()) &&\n               Objects.equals(timestamp, other.timestamp());\n    }\n\n    @Override\n    public String toString() {\n        return format + \" at \" + timestamp.toString();\n    }\n}\n"
  },
  {
    "path": "census/src/test/java/org/openjdk/skara/census/CensusTests.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.census;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.time.Instant;\nimport java.util.List;\nimport org.junit.jupiter.api.TestInfo;\nimport org.openjdk.skara.test.HostCredentials;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.vcs.Repository;\n\nclass CensusTests {\n    private Path createCensusDirectory() throws IOException {\n        var censusDir = Files.createTempDirectory(\"census\");\n\n        var contributorsFile = censusDir.resolve(\"contributors.xml\");\n        var contributorsContent = List.of(\n            \"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\",\n            \"<contributors>\",\n            \"    <contributor username=\\\"user1\\\" full-name=\\\"User One\\\" />\",\n            \"    <contributor username=\\\"user2\\\" full-name=\\\"User Two\\\" />\",\n            \"    <contributor username=\\\"user3\\\" full-name=\\\"User Three\\\" />\",\n            \"    <contributor username=\\\"user4\\\" full-name=\\\"User Four\\\" />\",\n            \"</contributors>\");\n        Files.write(contributorsFile, contributorsContent);\n\n        var groupsDir = censusDir.resolve(\"groups\");\n        Files.createDirectories(groupsDir);\n\n        var testGroupFile = groupsDir.resolve(\"test.xml\");\n        var testGroupContent = List.of(\n            \"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\",\n            \"<group name=\\\"group1\\\" full-name=\\\"Group One\\\">\",\n            \"    <lead username=\\\"user3\\\" />\",\n            \"    <member username=\\\"user1\\\" since=\\\"1\\\" />\",\n            \"    <member username=\\\"user2\\\" since=\\\"1\\\" />\",\n            \"</group>\");\n        Files.write(testGroupFile, testGroupContent);\n\n        var projectDir = censusDir.resolve(\"projects\");\n        Files.createDirectories(projectDir);\n\n        var testProjectFile = projectDir.resolve(\"test.xml\");\n        var testProjectContent = List.of(\n            \"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\",\n            \"<project name=\\\"project1\\\" full-name=\\\"Project One\\\" sponsor=\\\"group1\\\">\",\n            \"    <lead username=\\\"user1\\\" since=\\\"1\\\" />\",\n            \"    <reviewer username=\\\"user2\\\" since=\\\"1\\\" />\",\n            \"    <committer username=\\\"user3\\\" since=\\\"1\\\" />\",\n            \"    <author username=\\\"user4\\\" since=\\\"1\\\" />\",\n            \"</project>\");\n        Files.write(testProjectFile, testProjectContent);\n\n        var namespacesDir = censusDir.resolve(\"namespaces\");\n        Files.createDirectories(namespacesDir);\n\n        var namespaceFile = namespacesDir.resolve(\"github.xml\");\n        var namespaceContent = List.of(\n            \"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\",\n            \"<namespace name=\\\"github.com\\\">\",\n            \"    <user id=\\\"1234567\\\" census=\\\"user1\\\" />\",\n            \"    <user id=\\\"2345678\\\" census=\\\"user2\\\" />\",\n            \"</namespace>\");\n        Files.write(namespaceFile, namespaceContent);\n\n        var versionFile = censusDir.resolve(\"version.xml\");\n        var versionContent = List.of(\n            \"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\",\n            \"<version format=\\\"1\\\" timestamp=\\\"\" + Instant.now().toString() + \"\\\" />\");\n        Files.write(versionFile, versionContent);\n\n        return censusDir;\n    }\n\n    @Test\n    void testParseCensusDirectory() throws IOException {\n        var censusDir = createCensusDirectory();\n        var census = Census.parse(censusDir);\n\n        var c1 = new Contributor(\"user1\", \"User One\");\n        var c2 = new Contributor(\"user2\", \"User Two\");\n        var c3 = new Contributor(\"user3\", \"User Three\");\n        var c4 = new Contributor(\"user4\", \"User Four\");\n        assertEquals(List.of(c1, c2, c3, c4), census.contributors());\n\n        var g1 = new Group(\"group1\", \"Group One\", c3, List.of(c1, c2, c3));\n        assertEquals(List.of(g1), census.groups());\n\n        var p1 = new Project(\"project1\", \"Project One\", g1,\n                             List.of(new Member(c1, 1)), List.of(new Member(c2, 1)), List.of(new Member(c3, 1)), List.of(new Member(c4, 1)));\n        assertEquals(List.of(p1), census.projects());\n\n        var namespace = census.namespace(\"github.com\");\n        assertEquals(\"github.com\", namespace.name());\n        assertEquals(c1, namespace.get(\"1234567\"));\n        assertEquals(c2, namespace.get(\"2345678\"));\n        assertEquals(\"1234567\", namespace.get(c1));\n        assertEquals(\"2345678\", namespace.get(c2));\n\n        assertEquals(1, census.version().format());\n    }\n\n    @Test\n    void testParseSingleFile() throws IOException {\n        var contents = List.of(\n            \"<census time=\\\"2019-01-22T13:51:55-08:00\\\">\",\n            \"  <person name=\\\"user1\\\">\",\n            \"    <full-name>User One</full-name>\",\n            \"    <org>Org One</org>\",\n            \"  </person>\",\n            \"  <person name=\\\"user2\\\">\",\n            \"    <full-name>User Two</full-name>\",\n            \"    <org>Org Two</org>\",\n            \"  </person>\",\n            \"  <group name=\\\"group1\\\">\",\n            \"    <full-name>Group One</full-name>\",\n            \"    <person ref=\\\"user1\\\" role=\\\"lead\\\" />\",\n            \"    <person ref=\\\"user2\\\" />\",\n            \"  </group>\",\n            \"  <project name=\\\"project1\\\" >\",\n            \"    <full-name>Project One</full-name>\",\n            \"    <sponsor ref=\\\"group1\\\" />\",\n            \"    <person role=\\\"lead\\\" ref=\\\"user1\\\" />\",\n            \"    <person role=\\\"committer\\\" ref=\\\"user2\\\" />\",\n            \"  </project>\",\n            \"  <namespace name=\\\"reverse\\\" >\",\n            \"    <user id=\\\"1resu\\\" census=\\\"user1\\\" />\",\n            \"    <user id=\\\"2resu\\\" census=\\\"user2\\\" />\",\n            \"  </namespace>\",\n            \"</census>\");\n        var tmpFile = Files.createTempFile(\"census\", \".xml\");\n        Files.write(tmpFile, contents);\n        var census = Census.parse(tmpFile);\n\n        var contributor1 = new Contributor(\"user1\", \"User One\");\n        var contributor2 = new Contributor(\"user2\", \"User Two\");\n        assertEquals(List.of(contributor1, contributor2), census.contributors());\n\n        var group1 = new Group(\"group1\", \"Group One\", contributor1, List.of(contributor1, contributor2));\n        assertEquals(List.of(group1), census.groups());\n\n        var expectedProject = new Project(\"project1\", \"Project One\", group1,\n                                          List.of(new Member(contributor1)),\n                                          List.of(),\n                                          List.of(new Member(contributor2)),\n                                          List.of());\n        assertEquals(List.of(expectedProject), census.projects());\n\n        assertEquals(0, census.version().format());\n        assertEquals(1, census.namespaces().size());\n        assertEquals(2, census.namespace(\"reverse\").entries().size());\n        assertEquals(\"user1\", census.namespace(\"reverse\").get(\"1resu\").username());\n        assertEquals(\"user2\", census.namespace(\"reverse\").get(\"2resu\").username());\n\n        Files.delete(tmpFile);\n    }\n\n    @Test\n    void parseNamespace(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var censusRepo = credentials.getHostedRepository();\n\n            var repoPath = tempFolder.path().resolve(\"census\");\n            var localRepo = Repository.init(repoPath, censusRepo.repositoryType());\n\n            var namespacesDir = repoPath.resolve(\"namespaces\");\n            Files.createDirectories(namespacesDir);\n\n            var namespaceFile = namespacesDir.resolve(\"aspace.xml\");\n            var namespaceContent = List.of(\n                    \"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\",\n                    \"<namespace name=\\\"aspace\\\">\",\n                    \"    <user id=\\\"1234567\\\" census=\\\"user1\\\" />\",\n                    \"    <user id=\\\"2345678\\\" census=\\\"user2\\\" />\",\n                    \"</namespace>\");\n            Files.write(namespaceFile, namespaceContent);\n            localRepo.add(namespaceFile);\n\n            var contributorsFile = repoPath.resolve(\"contributors.xml\");\n            var contributorsContent = List.of(\n                    \"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\",\n                    \"<contributors>\",\n                    \"    <contributor username=\\\"user1\\\" full-name=\\\"User One\\\" />\",\n                    \"    <contributor username=\\\"user2\\\" full-name=\\\"User Two\\\" />\",\n                    \"    <contributor username=\\\"user3\\\" full-name=\\\"User Three\\\" />\",\n                    \"    <contributor username=\\\"user4\\\" full-name=\\\"User Four\\\" />\",\n                    \"</contributors>\");\n            Files.write(contributorsFile, contributorsContent);\n            localRepo.add(contributorsFile);\n\n            var masterHash = localRepo.commit(\"Add namespace and contributors\", \"testauthor\", \"ta@none.none\");\n\n            localRepo.push(masterHash, censusRepo.authenticatedUrl(), \"censusref\", true);\n\n            var namespace = Census.parseNamespace(censusRepo, \"censusref\", \"aspace\");\n\n            var c1 = new Contributor(\"user1\", \"User One\");\n            var c2 = new Contributor(\"user2\", \"User Two\");\n            assertEquals(\"aspace\", namespace.name());\n            assertEquals(c1, namespace.get(\"1234567\"));\n            assertEquals(c2, namespace.get(\"2345678\"));\n            assertEquals(\"1234567\", namespace.get(c1));\n            assertEquals(\"2345678\", namespace.get(c2));\n        }\n    }\n}\n"
  },
  {
    "path": "census/src/test/java/org/openjdk/skara/census/GroupTests.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.census;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass GroupTests {\n    @Test\n    void testContains() {\n        var members = List.of(new Contributor(\"user_1\", \"User Number 1\"));\n        var group = new Group(\"jdk\", \"JDK\", members.get(0), members);\n        assertTrue(group.contains(\"user_1\"));\n        assertFalse(group.contains(\"user_2\"));\n    }\n\n    @Test\n    void testMembers() {\n        var members = List.of(new Contributor(\"user_1\", \"User Number 1\"));\n        var group = new Group(\"jdk\", \"JDK\", members.get(0), members);\n        assertEquals(Set.of(new Contributor(\"user_1\", \"User Number 1\")), group.members());\n    }\n\n    @Test\n    void testLead() {\n        var members = List.of(new Contributor(\"user_1\", \"User Number 1\"));\n        var group = new Group(\"jdk\", \"JDK\", members.get(0), members);\n        assertEquals(new Contributor(\"user_1\", \"User Number 1\"), group.lead());\n    }\n}\n"
  },
  {
    "path": "census/src/test/java/org/openjdk/skara/census/MemberTests.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.census;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass MemberTests {\n    @Test\n    void testToString() {\n        var m = new Member(new Contributor(\"user_1\", \"User Number 1\"), 1);\n        assertEquals(\"user_1 (User Number 1) [1,)\", m.toString());\n\n        m = new Member(new Contributor(\"user_1\", \"User Number 1\"), 1, 2);\n        assertEquals(\"user_1 (User Number 1) [1,2)\", m.toString());\n    }\n}\n"
  },
  {
    "path": "census/src/test/java/org/openjdk/skara/census/ProjectTests.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.census;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass ProjectTests {\n    private static final Group group = new Group(\"testgroup\", \"Test Group\",\n                                                 new Contributor(\"user_5\", \"User Number 5\"),\n                                                 List.of());\n    @Test\n    void testIsLead() {\n        var leader = new Member(new Contributor(\"user_1\", \"User Number 1\"), 1, 2);\n        var project = new Project(\"jdk\", \"JDK\", group, List.of(leader), List.of(), List.of(), List.of());\n        assertTrue(project.isLead(\"user_1\", 1));\n        assertFalse(project.isLead(\"user_1\", 2));\n        assertFalse(project.isLead(\"user_1\", 0));\n\n        assertFalse(project.isLead(\"foo\", 1));\n    }\n\n    @Test\n    void testOngoingLeader() {\n        var leader = new Member(new Contributor(\"user_1\", \"User Number 1\"), 1);\n        var project = new Project(\"jdk\", \"JDK\", group, List.of(leader), List.of(), List.of(), List.of());\n        assertFalse(project.isLead(\"user_1\", 0));\n        assertTrue(project.isLead(\"user_1\", 1));\n        assertTrue(project.isLead(\"user_1\", 2));\n        assertTrue(project.isLead(\"user_1\", 3));\n        assertTrue(project.isLead(\"user_1\", 4));\n    }\n\n    @Test\n    void testMultipleLeaders() {\n        var leaders = List.of(new Member(new Contributor(\"user_1\", \"User Number 1\"), 1, 2),\n                              new Member(new Contributor(\"user_2\", \"User Number 2\"), 2, 3));\n        var project = new Project(\"jdk\", \"JDK\", group, leaders, List.of(), List.of(), List.of());\n        assertFalse(project.isLead(\"user_1\", 0));\n        assertFalse(project.isLead(\"user_2\", 0));\n\n        assertTrue(project.isLead(\"user_1\", 1));\n        assertFalse(project.isLead(\"user_2\", 1));\n\n        assertFalse(project.isLead(\"user_1\", 2));\n        assertTrue(project.isLead(\"user_2\", 2));\n\n        assertFalse(project.isLead(\"user_1\", 3));\n        assertFalse(project.isLead(\"user_2\", 3));\n    }\n\n    @Test\n    void testLeader() {\n        var leader = new Member(new Contributor(\"user_1\", \"User Number 1\"), 1, 2);\n        var project = new Project(\"jdk\", \"JDK\", group, List.of(leader), List.of(), List.of(), List.of());\n        assertNull(project.lead(0));\n        assertEquals(new Contributor(\"user_1\", \"User Number 1\"), project.lead(1));\n        assertNull(project.lead(2));\n    }\n\n    private Project sampleSingletonProject() {\n        var leader = new Member(new Contributor(\"user_1\", \"User Number 1\"), 1);\n        var reviewer = new Member(new Contributor(\"user_2\", \"User Number 2\"), 1);\n        var committer = new Member(new Contributor(\"user_3\", \"User Number 3\"), 1);\n        var author = new Member(new Contributor(\"user_4\", \"User Number 4\"), 1);\n\n        return new Project(\"jdk\", \"JDK\", group,\n                           List.of(leader),\n                           List.of(reviewer),\n                           List.of(committer),\n                           List.of(author));\n    }\n\n    @Test\n    void testIsReviewer() {\n        var project = sampleSingletonProject();\n\n        assertFalse(project.isReviewer(\"user_1\", 0));\n        assertFalse(project.isReviewer(\"user_2\", 0));\n        assertFalse(project.isReviewer(\"user_3\", 0));\n        assertFalse(project.isReviewer(\"user_4\", 0));\n\n\n        assertTrue(project.isReviewer(\"user_1\", 1));\n        assertTrue(project.isReviewer(\"user_2\", 1));\n        assertFalse(project.isReviewer(\"user_3\", 1));\n        assertFalse(project.isReviewer(\"user_4\", 1));\n\n        assertFalse(project.isReviewer(\"foo\", 1));\n    }\n\n    @Test\n    void testReviewers() {\n        var project = sampleSingletonProject();\n\n        var expected = Set.of(new Contributor(\"user_1\", \"User Number 1\"),\n                              new Contributor(\"user_2\", \"User Number 2\"));\n        var actual = Set.copyOf(project.reviewers(1));\n\n        assertEquals(expected, actual);\n    }\n\n    @Test\n    void testIsCommitter() {\n        var project = sampleSingletonProject();\n\n        assertFalse(project.isCommitter(\"user_1\", 0));\n        assertFalse(project.isCommitter(\"user_2\", 0));\n        assertFalse(project.isCommitter(\"user_3\", 0));\n        assertFalse(project.isCommitter(\"user_4\", 0));\n\n\n        assertTrue(project.isCommitter(\"user_1\", 1));\n        assertTrue(project.isCommitter(\"user_2\", 1));\n        assertTrue(project.isCommitter(\"user_3\", 1));\n        assertFalse(project.isCommitter(\"user_4\", 1));\n\n        assertFalse(project.isCommitter(\"foo\", 1));\n    }\n\n    @Test\n    void testCommitters() {\n        var project = sampleSingletonProject();\n\n        var expected = Set.of(new Contributor(\"user_1\", \"User Number 1\"),\n                              new Contributor(\"user_2\", \"User Number 2\"),\n                              new Contributor(\"user_3\", \"User Number 3\"));\n        var actual = Set.copyOf(project.committers(1));\n\n        assertEquals(expected, actual);\n    }\n\n    @Test\n    void testIsAuthor() {\n        var project = sampleSingletonProject();\n\n        assertFalse(project.isAuthor(\"user_1\", 0));\n        assertFalse(project.isAuthor(\"user_2\", 0));\n        assertFalse(project.isAuthor(\"user_3\", 0));\n        assertFalse(project.isAuthor(\"user_4\", 0));\n\n\n        assertTrue(project.isAuthor(\"user_1\", 1));\n        assertTrue(project.isAuthor(\"user_2\", 1));\n        assertTrue(project.isAuthor(\"user_3\", 1));\n        assertTrue(project.isAuthor(\"user_4\", 1));\n\n        assertFalse(project.isAuthor(\"foo\", 1));\n    }\n}\n"
  },
  {
    "path": "ci/build.gradle",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.ci'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.ci' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':host')\n    implementation project(':json')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n}\n"
  },
  {
    "path": "ci/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.ci {\n    requires org.openjdk.skara.host;\n    requires org.openjdk.skara.json;\n    requires org.openjdk.skara.forge;\n\n    uses org.openjdk.skara.ci.ContinuousIntegrationFactory;\n    exports org.openjdk.skara.ci;\n}\n"
  },
  {
    "path": "ci/src/main/java/org/openjdk/skara/ci/Build.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.ci;\n\nimport java.util.Objects;\n\npublic class Build {\n    public static enum OperatingSystem {\n        WINDOWS,\n        MACOS,\n        LINUX,\n        SOLARIS,\n        AIX,\n        FREEBSD,\n        OPENBSD,\n        NETBSD,\n        HPUX,\n        HAIKU\n    }\n\n    public static enum CPU {\n        X86,\n        X64,\n        SPARCV9,\n        AARCH64,\n        AARCH32,\n        PPCLE32,\n        PPCLE64\n    }\n\n    public static enum DebugLevel {\n        RELEASE,\n        FASTDEBUG,\n        SLOWDEBUG\n    }\n\n    private final OperatingSystem os;\n    private final CPU cpu;\n    private final DebugLevel debugLevel;\n\n    public Build(OperatingSystem os, CPU cpu, DebugLevel debugLevel) {\n        this.os = os;\n        this.cpu = cpu;\n        this.debugLevel = debugLevel;\n    }\n\n    public OperatingSystem os() {\n        return os;\n    }\n\n    public CPU cpu() {\n        return cpu;\n    }\n\n    public DebugLevel debugLevel() {\n        return debugLevel;\n    }\n\n    @Override\n    public String toString() {\n        return os.toString().toLowerCase() + \"-\" +\n               cpu.toString().toLowerCase() + \"-\" +\n               debugLevel.toString().toLowerCase();\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(os, cpu, debugLevel);\n    }\n\n    @Override\n    public boolean equals(Object other) {\n        if (other == this) {\n            return true;\n        }\n\n        if (!(other instanceof Build o)) {\n            return false;\n        }\n\n        return Objects.equals(os, o.os) &&\n               Objects.equals(cpu, o.cpu) &&\n               Objects.equals(debugLevel, o.debugLevel);\n    }\n}\n"
  },
  {
    "path": "ci/src/main/java/org/openjdk/skara/ci/ContinuousIntegration.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.ci;\n\nimport org.openjdk.skara.host.Host;\nimport org.openjdk.skara.json.JSONObject;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.forge.PullRequest;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.file.Path;\nimport java.util.*;\n\npublic interface ContinuousIntegration extends Host {\n    Job submit(Path source, List<String> jobs, String id) throws IOException;\n    Job job(String id) throws IOException;\n    List<Job> jobsFor(PullRequest pr) throws IOException;\n    void cancel(String id) throws IOException;\n\n    static Optional<ContinuousIntegration> from(URI uri, JSONObject configuration) {\n        for (var factory : ContinuousIntegrationFactory.factories()) {\n            var ci = factory.create(uri, configuration);\n            if (ci.isValid()) {\n                return Optional.of(ci);\n            }\n        }\n        return Optional.empty();\n    }\n\n    static Optional<ContinuousIntegration> from(URI uri) {\n        return from(uri, JSON.object());\n    }\n}\n"
  },
  {
    "path": "ci/src/main/java/org/openjdk/skara/ci/ContinuousIntegrationFactory.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.ci;\n\nimport org.openjdk.skara.json.JSONObject;\n\nimport java.net.URI;\nimport java.util.*;\nimport java.util.stream.*;\n\npublic interface ContinuousIntegrationFactory {\n    ContinuousIntegration create(URI uri, JSONObject configuration);\n\n    static List<ContinuousIntegrationFactory> factories() {\n        return StreamSupport.stream(ServiceLoader.load(ContinuousIntegrationFactory.class).spliterator(), false)\n                            .collect(Collectors.toList());\n    }\n}\n"
  },
  {
    "path": "ci/src/main/java/org/openjdk/skara/ci/Job.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.ci;\n\nimport java.util.List;\n\npublic interface Job {\n    static class Status {\n        private final int numCompleted;\n        private final int numRunning;\n        private final int numNotStarted;\n\n        public Status(int numCompleted, int numRunning, int numNotStarted) {\n            this.numCompleted = numCompleted;\n            this.numRunning = numRunning;\n            this.numNotStarted = numNotStarted;\n        }\n\n        public int numCompleted() {\n            return numCompleted;\n        }\n\n        public int numRunning() {\n            return numRunning;\n        }\n\n        public int numNotStarted() {\n            return numNotStarted;\n        }\n\n        public int numTotal() {\n            return numCompleted + numRunning + numNotStarted;\n        }\n    }\n\n    static class Result {\n        private final int numPassed;\n        private final int numFailed;\n        private final int numSkipped;\n\n        public Result(int numPassed, int numFailed, int numSkipped) {\n            this.numPassed = numPassed;\n            this.numFailed = numFailed;\n            this.numSkipped = numSkipped;\n        }\n\n        public int numPassed() {\n            return numPassed;\n        }\n\n        public int numFailed() {\n            return numFailed;\n        }\n\n        public int numSkipped() {\n            return numSkipped;\n        }\n\n        public int numTotal() {\n            return numPassed + numFailed + numSkipped;\n        }\n    }\n\n    String id();\n    List<Build> builds();\n    List<Test> tests();\n    Status status();\n    Result result();\n\n    static enum State {\n        COMPLETED,\n        RUNNING,\n        SCHEDULED\n    }\n    State state();\n    default boolean isCompleted() {\n        return state() == State.COMPLETED;\n    }\n    default boolean isRunning() {\n        return state() == State.COMPLETED;\n    }\n    default boolean isScheduled() {\n        return state() == State.SCHEDULED;\n    }\n}\n"
  },
  {
    "path": "ci/src/main/java/org/openjdk/skara/ci/Test.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.ci;\n\npublic class Test {\n    public static enum Kind {\n        SINGLE,\n        GROUP\n    }\n\n    private final Kind kind;\n    private final String name;\n\n    public Test(Kind kind, String name) {\n        this.kind = kind;\n        this.name = name;\n    }\n\n    public Kind kind() {\n        return kind;\n    }\n\n    public String name() {\n        return name;\n    }\n}\n"
  },
  {
    "path": "cli/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nplugins {\n    id 'skara-images'\n}\n\nmodule {\n    name = 'org.openjdk.skara.cli'\n    test {\n        requires 'org.openjdk.skara.test'\n        requires 'org.junit.jupiter.api'\n        opens 'org.openjdk.skara.cli.debug' to 'org.junit.platform.commons'\n    }\n\n}\n\ndependencies {\n    implementation project(':args')\n    implementation project(':census')\n    implementation project(':ini')\n    implementation project(':jcheck')\n    implementation project(':vcs')\n    implementation project(':webrev')\n    implementation project(':json')\n    implementation project(':host')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':proxy')\n    implementation project(':version')\n    implementation project(':process')\n    implementation project(':jbs')\n    implementation project(':network')\n\n    testImplementation project(':test')\n}\n\n// Load deps.env and remove all double quotes\ndef depsEnv = new Properties()\nfile(\"../deps.env\").withInputStream { { depsEnv.load(it)}}\ndepsEnv.entrySet().forEach(e -> e.setValue(((String) e.getValue()).replaceAll(\"\\\"\", \"\")))\n\nimages {\n    ext.launchers = [\n        'git-jcheck': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitJCheck',\n        'git-webrev': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitWebrev',\n        'git-defpath': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitDefpath',\n        'git-fork': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitFork',\n        'git-pr': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitPr',\n        'git-token': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitToken',\n        'git-info': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitInfo',\n        'git-translate': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitTranslate',\n        'git-skara': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitSkara',\n        'git-sync': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitSync',\n        'git-publish': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitPublish',\n        'git-proxy': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitProxy',\n        'git-trees': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitTrees',\n        'git-hg-export': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitHgExport',\n        'git-backport': 'org.openjdk.skara.cli/org.openjdk.skara.cli.GitBackport'\n    ]\n\n    ext.modules = ['jdk.crypto.ec']\n\n    windows_x64 {\n        modules = ext.modules\n        launchers = ext.launchers\n        bundles = ['zip', 'tar.gz']\n        jdk {\n            url = depsEnv.getProperty(\"JDK_WINDOWS_X64_URL\")\n            sha256 = depsEnv.getProperty(\"JDK_WINDOWS_X64_SHA256\")\n        }\n    }\n\n    linux_x64 {\n        modules = ext.modules\n        launchers = ext.launchers\n        man = 'cli/resources/man'\n        bundles = ['zip', 'tar.gz']\n        jdk {\n            url = depsEnv.getProperty(\"JDK_LINUX_X64_URL\")\n            sha256 = depsEnv.getProperty(\"JDK_LINUX_X64_SHA256\")\n        }\n    }\n\n    macos_x64 {\n        modules = ext.modules\n        launchers = ext.launchers\n        man = 'cli/resources/man'\n        bundles = ['zip', 'tar.gz']\n        jdk {\n            url = depsEnv.getProperty(\"JDK_MACOS_X64_URL\")\n            sha256 = depsEnv.getProperty(\"JDK_MACOS_X64_SHA256\")\n        }\n    }\n\n    local {\n        modules = ext.modules\n        launchers = ext.launchers\n        man = 'cli/resources/man'\n        bundles = ['zip', 'tar.gz']\n    }\n}\n"
  },
  {
    "path": "cli/resources/man/man1/git-jcheck.1",
    "content": "\\\"\n\\\" Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n\\\" DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n\\\"\n\\\" This code is free software; you can redistribute it and/or modify it\n\\\" under the terms of the GNU General Public License version 2 only, as\n\\\" published by the Free Software Foundation.\n\\\"\n\\\" This code is distributed in the hope that it will be useful, but WITHOUT\n\\\" ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n\\\" FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n\\\" version 2 for more details (a copy is included in the LICENSE file that\n\\\" accompanied this code).\n\\\"\n\\\" You should have received a copy of the GNU General Public License version\n\\\" 2 along with this work; if not, write to the Free Software Foundation,\n\\\" Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n\\\"\n\\\" Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n\\\" or visit www.oracle.com if you need additional information or have any\n\\\" questions.\n\\\"\n.TH GIT-JCHECK 1\n.SH NAME\ngit-jcheck \\- check that changes conform to OpenJDK standards\n.SH SYNOPSIS\n\\fIgit jcheck\\fR [<revision range>]\n.SH DESCRIPTION\nCheck that changes conforms to OpenJDK standards.\n.PP\n.TP\n\\fIgit jcheck\\fR [<revision range>]\nCheck changes in <revision range> (defaults to HEAD^..HEAD).\n.SH OPTIONS\nNone\n.SH SEE ALSO\n\\fBgit-diff(1)\\fR, \\fBgitrevisions(7)\\fR\n"
  },
  {
    "path": "cli/resources/man/man1/git-verify-import.1",
    "content": "\\\"\n\\\" Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n\\\" DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n\\\"\n\\\" This code is free software; you can redistribute it and/or modify it\n\\\" under the terms of the GNU General Public License version 2 only, as\n\\\" published by the Free Software Foundation.\n\\\"\n\\\" This code is distributed in the hope that it will be useful, but WITHOUT\n\\\" ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n\\\" FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n\\\" version 2 for more details (a copy is included in the LICENSE file that\n\\\" accompanied this code).\n\\\"\n\\\" You should have received a copy of the GNU General Public License version\n\\\" 2 along with this work; if not, write to the Free Software Foundation,\n\\\" Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n\\\"\n\\\" Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n\\\" or visit www.oracle.com if you need additional information or have any\n\\\" questions.\n\\\"\n.TH GIT-VERIFY-IMPORT 1\n.SH NAME\ngit-verify-import \\- verifies that a fast-import was done correctly\n.SH SYNOPSIS\n\\fIgit verify-import\\fR <hg-repository>\n.SH DESCRIPTION\nVerifies that a fast-import from a Mercurial (hg) repository was done correctly.\n.PP\n.TP\n\\fIgit verify-import\\fR <hg-repository>\nCheck that the repository <hg-repository> was successfully imported.\n.SH OPTIONS\n.TP\n-h, --help\nShow help message\n.PP\n.TP\n-v, --verbose\nTurn on verbose output, useful for debugging\n.SH SEE ALSO\n\\fBgit-fast-import(1)\\fR\n"
  },
  {
    "path": "cli/resources/man/man1/git-webrev.1",
    "content": "\\\"\n\\\" Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n\\\" DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n\\\"\n\\\" This code is free software; you can redistribute it and/or modify it\n\\\" under the terms of the GNU General Public License version 2 only, as\n\\\" published by the Free Software Foundation.\n\\\"\n\\\" This code is distributed in the hope that it will be useful, but WITHOUT\n\\\" ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n\\\" FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n\\\" version 2 for more details (a copy is included in the LICENSE file that\n\\\" accompanied this code).\n\\\"\n\\\" You should have received a copy of the GNU General Public License version\n\\\" 2 along with this work; if not, write to the Free Software Foundation,\n\\\" Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n\\\"\n\\\" Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n\\\" or visit www.oracle.com if you need additional information or have any\n\\\" questions.\n\\\"\n.TH GIT-WEBREV 1\n.SH NAME\ngit-webrev \\- render a HTML view of changes\n.SH SYNOPSIS\n\\fIgit webrev\\fR [options]\n.SH DESCRIPTION\nRender a HTML view of the changes between two commits.\n.PP\n.TP\n\\fIgit webrev\\fR \nCreates a webrev with changes between the last two commits, and puts the resoult in a directory named 'webrev'\n.SH OPTIONS\n.TP\n-r, --rev=\\fIREV\\fR\nCompare against a specified revision, \n.PP\n.TP\n-o, --output=\\fIDIR\\fR\nOutput webrev to DIR. Defaults to 'webrev'.\n.PP\n.TP\n-u, --username=\\fINAME\\fR\nUse that username instead of 'guessing' one\n.PP\n.TP\n    --repository=\\fIURL\\fR\nThe upstream repository for this local repository. Defaults to the 'origin' (or 'default') URL.\n.PP\n.TP\n-t, --title=\\fITITLE\\fR\nThe title of the webrev. Defaults to the basename of the current working directory.\n.PP\n.TP\n-c, --cr=\\fICR\\fR\nInclude link to CR (aka bugid) in the main page\n.PP\n.TP\n-b,\nDo not ignore changes in whitespace (always true)\n.PP\n.TP\n-m, --mercurial\nDeprecated: force use of mercurial\n.PP\n.TP\n-C, --no-comments\nDon't show comments\n.PP\n.TP\n-N, --no-outgoing\nDo not compare against remote, use only 'status'\n.PP\n.TP\n-v, --version\nPrint the version of this tool\n.PP\n.TP\n-h, --help\nShow help text\n.PP\n.SH SEE ALSO\n\\fBgit-diff\\fR(1)\n"
  },
  {
    "path": "cli/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2018, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.cli {\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.jcheck;\n    requires org.openjdk.skara.census;\n    requires org.openjdk.skara.webrev;\n    requires org.openjdk.skara.args;\n    requires org.openjdk.skara.json;\n    requires org.openjdk.skara.host;\n    requires org.openjdk.skara.forge;\n    requires org.openjdk.skara.proxy;\n    requires org.openjdk.skara.version;\n    requires org.openjdk.skara.process;\n    requires org.openjdk.skara.jbs;\n    requires org.openjdk.skara.network;\n\n    requires java.net.http;\n    requires java.logging;\n\n    exports org.openjdk.skara.cli;\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/ForgeUtils.java",
    "content": "/*\n * Copyright (c) 2021, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.Arguments;\nimport org.openjdk.skara.forge.Forge;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.vcs.ReadOnlyRepository;\nimport org.openjdk.skara.vcs.Repository;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Path;\nimport java.util.Optional;\n\npublic class ForgeUtils {\n    private static void exit(String fmt, Object... args) {\n        System.err.println(String.format(fmt, args));\n        System.exit(1);\n    }\n\n    private static void gitConfig(String key, String value) {\n        try {\n            var pb = new ProcessBuilder(\"git\", \"config\", key, value);\n            pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);\n            pb.redirectError(ProcessBuilder.Redirect.DISCARD);\n            pb.start().waitFor();\n        } catch (InterruptedException e) {\n            // do nothing\n        } catch (IOException e) {\n            // do nothing\n        }\n    }\n\n    private static String gitConfig(String key) {\n        try {\n            var pb = new ProcessBuilder(\"git\", \"config\", key);\n            pb.redirectOutput(ProcessBuilder.Redirect.PIPE);\n            pb.redirectError(ProcessBuilder.Redirect.DISCARD);\n            var p = pb.start();\n\n            var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);\n            var res = p.waitFor();\n            if (res != 0) {\n                return null;\n            }\n\n            return output == null ? null : output.replace(\"\\n\", \"\");\n        } catch (InterruptedException e) {\n            return null;\n        } catch (IOException e) {\n            return null;\n        }\n    }\n\n    public static String getOption(String name, String command, String subsection, Arguments arguments) {\n        if (arguments.contains(name)) {\n            return getArgument(name, arguments);\n        }\n\n        if (subsection != null && !subsection.isEmpty()) {\n            var subsectionSpecific = gitConfig(command + \".\" + subsection + \".\" + name);\n            if (subsectionSpecific != null) {\n                return subsectionSpecific;\n            }\n        }\n\n        return gitConfig(command + \".\" + name);\n    }\n\n    // Returning null means that this option is not displayed in the command.\n    // If this option is not followed by an argument, the program will exit and report an error.\n    public static String getOption(String name, Arguments arguments) {\n        if (arguments.contains(name)) {\n            return getArgument(name, arguments);\n        }\n        return null;\n    }\n\n    public static String getOption(String name, Arguments arguments, String defaultVal) {\n        if (arguments.contains(name)) {\n            return getArgument(name, arguments);\n        }\n        return defaultVal;\n    }\n\n    private static String getArgument(String name, Arguments arguments) {\n        var arg = arguments.get(name);\n        if (!arg.isPresent()) {\n            System.err.printf(\"error: a non-empty value is needed for '%s'%n\", name);\n            System.exit(1);\n        }\n        return arg.asString();\n    }\n\n    public static boolean getSwitch(String name, String command, String subsection, Arguments arguments) {\n        if (arguments.contains(name)) {\n            return true;\n        }\n\n        if (subsection != null && !subsection.isEmpty()) {\n            var subsectionSpecific = gitConfig(command + \".\" + subsection + \".\" + name);\n            if (subsectionSpecific != null) {\n                return subsectionSpecific.toLowerCase().equals(\"true\");\n            }\n        }\n\n        var sectionSpecific = gitConfig(command + \".\" + name);\n        return sectionSpecific != null && sectionSpecific.toLowerCase().equals(\"true\");\n    }\n\n    static Repository getRepo() throws IOException {\n        var cwd = Path.of(\"\").toAbsolutePath();\n        return Repository.get(cwd).orElseThrow(() -> new IOException(\"no git repository found at \" + cwd.toString()));\n    }\n\n    public static String getRemote(ReadOnlyRepository repo, String command, Arguments arguments) throws IOException {\n        var remote = getOption(\"remote\", command, null, arguments);\n        return remote == null ? \"origin\" : remote;\n    }\n\n    public static URI getURI(ReadOnlyRepository repo, String command, Arguments arguments) throws IOException {\n        var remotePullPath = repo.pullPath(getRemote(repo, command, arguments));\n        return Remote.toWebURI(remotePullPath);\n    }\n\n    public static Optional<Forge> from(URI uri) {\n        return from(uri, null);\n    }\n\n    public static Optional<Forge> from(URI uri, Credential credentials) {\n        var name = gitConfig(\"forge.name\");\n        if (name != null) {\n            var forge = credentials == null ? Forge.from(name, uri) : Forge.from(name, uri, credentials);\n            return Optional.of(forge);\n        }\n        var forge = credentials == null ? Forge.from(uri) : Forge.from(uri, credentials);\n        if (forge.isPresent()) {\n            gitConfig(\"forge.name\", forge.get().name().toLowerCase());\n        }\n        return forge;\n    }\n\n    public static Forge getForge(URI uri, ReadOnlyRepository repo, String command, Arguments arguments) throws IOException {\n        var username = getOption(\"username\", null, null, arguments);\n        var token = System.getenv(\"GIT_TOKEN\");\n        var shouldUseToken = !getSwitch(\"no-token\", command, null, arguments);\n        var credentials = !shouldUseToken ?\n                null :\n                GitCredentials.fill(uri.getHost(), uri.getPath(), username, token, uri.getScheme());\n        var forgeURI = URI.create(uri.getScheme() + \"://\" + uri.getHost());\n        var forge = credentials == null ?\n                from(forgeURI) :\n                from(forgeURI, new Credential(credentials.username(), credentials.password()));\n        if (forge.isEmpty()) {\n            if (!shouldUseToken) {\n                if (arguments.contains(\"verbose\")) {\n                    System.err.println(\"\");\n                }\n                System.err.println(\"warning: using this command with --no-token may result in rate limiting from \" + forgeURI);\n                if (!arguments.contains(\"verbose\")) {\n                    System.err.println(\"         Re-run with --verbose to see if you are being rate limited\");\n                    System.err.println(\"\");\n                }\n            }\n            exit(\"error: failed to connect to host: \" + forgeURI);\n        }\n        if (credentials != null) {\n            GitCredentials.approve(credentials);\n        }\n        return forge.get();\n    }\n\n    public static String projectName(URI uri) {\n        var name = uri.getPath().toString().substring(1);\n        if (name.endsWith(\".git\")) {\n            name = name.substring(0, name.length() - \".git\".length());\n        }\n        return name;\n    }\n\n    public static HostedRepository getHostedRepositoryFor(URI uri, ReadOnlyRepository repo, Forge host) throws IOException {\n        HostedRepository targetRepo = null;\n\n        try {\n            var upstream = Remote.toWebURI(repo.pullPath(\"upstream\"));\n            targetRepo = host.repository(projectName(upstream)).orElse(null);\n        } catch (IOException e) {\n            // do nothing\n        }\n\n        if (targetRepo == null) {\n            var remoteRepo = host.repository(projectName(uri)).orElseThrow(() ->\n                    new IOException(\"Could not find repository at: \" + uri.toString())\n            );\n            var parentRepo = remoteRepo.parent();\n            targetRepo = parentRepo.isPresent() ? parentRepo.get() : remoteRepo;\n        }\n\n        return targetRepo;\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitBackport.java",
    "content": "/*\n * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.version.Version;\nimport org.openjdk.skara.proxy.HttpProxy;\n\nimport java.io.IOException;\nimport java.net.*;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.logging.Level;\n\npublic class GitBackport {\n    private static String getOption(String name, Arguments arguments, ReadOnlyRepository repo) throws IOException {\n        var arg = ForgeUtils.getOption(name, arguments);\n        if (arg != null) {\n            return arg;\n        }\n        var lines = repo.config(\"backport.\" + name);\n        return lines.size() == 1 ? lines.get(0) : null;\n    }\n\n    private static void run(Repository repo, String... args) throws IOException {\n        var pb = new ProcessBuilder(args);\n        pb.inheritIO();\n        pb.directory(repo.root().toFile());\n        try {\n            var err = pb.start().waitFor();\n            if (err != 0) {\n                System.exit(err);\n            }\n        } catch (InterruptedException e) {\n            throw new IOException(e);\n        }\n    }\n\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"u\")\n              .fullname(\"username\")\n              .describe(\"NAME\")\n              .helptext(\"Username on host\")\n              .optional(),\n        Option.shortcut(\"\")\n              .fullname(\"from\")\n              .describe(\"REPO\")\n              .helptext(\"Repository to backport from\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n         Input.position(0)\n              .describe(\"HASH\")\n              .singular()\n              .required()\n    );\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var parser = new ArgumentParser(\"git-backport\", flags, inputs);\n        var arguments = parser.parse(args);\n\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-backport version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            Logging.setup(level);\n        }\n\n        HttpProxy.setup();\n\n        var cwd = Paths.get(\"\").toAbsolutePath();\n        var repository = Repository.get(cwd);\n        if (repository.isEmpty()) {\n            System.err.println(\"error: no repository found at \" + cwd.toString());\n            System.exit(1);\n        }\n        var repo = repository.get();\n\n        var from = getOption(\"from\", arguments, repo);\n        if (from == null) {\n            System.err.println(\"error: must specify repository to backport from using --from\");\n        }\n\n        var commit = arguments.at(0).asString();\n\n        var gitUsername = repo.config(\"user.name\");\n        if (gitUsername.size() != 1) {\n            System.err.println(\"error: user.name not configured\");\n            System.exit(1);\n        }\n\n        var gitEmail = repo.config(\"user.email\");\n        if (gitEmail.size() != 1) {\n            System.err.println(\"error: user.email not configured\");\n            System.exit(1);\n        }\n\n        URI fromURI = null;\n        try {\n            fromURI = Remote.toURI(from, false);\n        } catch (IOException e) {\n            var origin = Remote.toURI(repo.pullPath(\"origin\"), false);\n            var dotGit = origin.getPath().endsWith(\".git\") ? \".git\" : \"\";\n            if (from.contains(\"/\")) {\n                fromURI = URI.create(origin.getScheme() + \"://\" + origin.getHost() + \"/\" + from + dotGit);\n            } else {\n                var canonical = Remote.toWebURI(Remote.toURI(repo.pullPath(\"origin\"), true).toString());\n                var username = getOption(\"username\", arguments, repo);\n                var token = System.getenv(\"GIT_TOKEN\");\n                var credentials = GitCredentials.fill(canonical.getHost(), canonical.getPath(), username, token, canonical.getScheme());\n                var forgeURI = URI.create(canonical.getScheme() + \"://\" + canonical.getHost());\n                var forge = ForgeUtils.from(forgeURI, new Credential(credentials.username(), credentials.password()));\n                if (forge.isEmpty()) {\n                    System.err.println(\"error: could not find forge at \" + forgeURI.getHost());\n                    System.exit(1);\n                }\n                var originRemoteRepository = forge.get().repository(canonical.getPath().substring(1));\n                if (originRemoteRepository.isEmpty()) {\n                    System.err.println(\"error: could not find repository named '\" + origin.getPath().substring(1) + \"' on \" + forge.get().hostname());\n                    System.exit(1);\n                }\n                var upstreamRemoteRepository = originRemoteRepository.get().parent();\n                if (upstreamRemoteRepository.isEmpty()) {\n                    System.err.println(\"error: the repository named '\" + originRemoteRepository.get().name() + \" is not a fork of another repository\");\n                    System.exit(1);\n                }\n                var upstreamGroup = upstreamRemoteRepository.get().webUrl().getPath().substring(1).split(\"/\")[0];\n                fromURI = URI.create(origin.getScheme() + \"://\" +\n                                     origin.getHost() + \"/\" +\n                                     upstreamGroup + \"/\" +\n                                     from +\n                                     dotGit);\n            }\n        }\n\n        System.out.println(\"Fetching ...\");\n        System.out.flush();\n        var fetchHead = repo.fetch(fromURI, commit, false).orElseThrow();\n\n        System.out.println(\"Cherry picking ...\");\n        System.out.flush();\n        run(repo, \"git\", \"cherry-pick\", \"--no-commit\",\n                                        \"--keep-redundant-commits\",\n                                        \"--strategy=recursive\",\n                                        \"--strategy-option=patience\",\n                                        fetchHead.hex());\n\n        System.out.println(\"Committing ...\");\n        System.out.flush();\n        run(repo, \"git\", \"commit\", \"--quiet\", \"--message=\" + \"Backport \" + fetchHead.hex());\n\n        System.out.println(\"Commit \" + fetchHead.hex() + \" successfully backported as commit \" + repo.head().hex());\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitCommitComments.java",
    "content": "/*\n * Copyright (c) 2021, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.proxy.HttpProxy;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.nio.charset.StandardCharsets;\nimport java.util.*;\nimport java.util.function.Supplier;\nimport java.util.logging.Level;\nimport java.time.format.*;\n\npublic class GitCommitComments {\n    private static void exit(String fmt, Object...args) {\n        System.err.println(String.format(fmt, args));\n        System.exit(1);\n    }\n\n    private static <T> Supplier<T> die(String fmt, Object... args) {\n        return () -> {\n            exit(fmt, args);\n            return null;\n        };\n    }\n\n    private static void sleep(int ms) {\n        try {\n            Thread.sleep(ms);\n        } catch (InterruptedException e) {\n            // do nothing\n        }\n    }\n\n    private static String getOption(String name, Arguments arguments) {\n        return ForgeUtils.getOption(name, \"cc\", null, arguments);\n    }\n\n    private static boolean getSwitch(String name, String subsection, Arguments arguments) {\n        if (arguments.contains(name)) {\n            return true;\n        }\n\n        if (subsection != null && !subsection.isEmpty()) {\n            var subsectionSpecific = gitConfig(\"fork.\" + subsection + \".\" + name);\n            if (subsectionSpecific != null) {\n                return subsectionSpecific.toLowerCase().equals(\"true\");\n            }\n        }\n\n        var sectionSpecific = gitConfig(\"cc.\" + name);\n        return sectionSpecific != null && sectionSpecific.toLowerCase().equals(\"true\");\n    }\n\n    private static String gitConfig(String key) {\n        try {\n            var pb = new ProcessBuilder(\"git\", \"config\", key);\n            pb.redirectOutput(ProcessBuilder.Redirect.PIPE);\n            pb.redirectError(ProcessBuilder.Redirect.DISCARD);\n            var p = pb.start();\n\n            var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);\n            var res = p.waitFor();\n            if (res != 0) {\n                return null;\n            }\n\n            return output == null ? null : output.replace(\"\\n\", \"\");\n        } catch (InterruptedException e) {\n            return null;\n        } catch (IOException e) {\n            return null;\n        }\n    }\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var flags = List.of(\n            Option.shortcut(\"u\")\n                  .fullname(\"username\")\n                  .describe(\"NAME\")\n                  .helptext(\"Username on host\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"verbose\")\n                  .helptext(\"Turn on verbose output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"debug\")\n                  .helptext(\"Turn on debugging output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"version\")\n                  .helptext(\"Print the version of this tool\")\n                  .optional()\n        );\n\n        var inputs = List.of(\n            Input.position(0)\n                 .describe(\"URI\")\n                 .singular()\n                 .required()\n        );\n\n        var parser = new ArgumentParser(\"git-cc\", flags, inputs);\n        var arguments = parser.parse(args);\n\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-cc version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            Logging.setup(level);\n        }\n\n        HttpProxy.setup();\n\n        URI uri = null;\n        if (arguments.at(0).isPresent()) {\n            var arg = arguments.at(0).asString();\n            var argURI = URI.create(arg);\n            uri = argURI.getScheme() == null ?\n                URI.create(\"https://\" + argURI.getHost() + argURI.getPath()) :\n                argURI;\n        } else {\n            exit(\"error: must supply URI\");\n        }\n\n        if (uri == null) {\n            exit(\"error: not a valid URI: \" + uri);\n        }\n\n        var webURI = Remote.toWebURI(uri.toString());\n        var token = System.getenv(\"GIT_TOKEN\");\n        var username = getOption(\"username\", arguments);\n        var credentials = GitCredentials.fill(webURI.getHost(), webURI.getPath(), username, token, webURI.getScheme());\n\n        if (credentials.password() == null) {\n            exit(\"error: no personal access token found, use git-credentials or the environment variable GIT_TOKEN\");\n        }\n        if (credentials.username() == null) {\n            exit(\"error: no username for \" + webURI.getHost() + \" found, use git-credentials or the flag --username\");\n        }\n\n        var host = ForgeUtils.from(webURI, new Credential(credentials.username(), credentials.password()));\n        if (host.isEmpty()) {\n            exit(\"error: could not connect to host \" + webURI.getHost());\n        }\n\n        var repositoryPath = webURI.getPath().substring(1);\n\n        if (repositoryPath.endsWith(\"/\")) {\n            repositoryPath =\n                    repositoryPath.substring(0, repositoryPath.length() - 1);\n        }\n\n        var hostedRepo = host.get().repository(repositoryPath).orElseThrow(() ->\n            new IOException(\"Could not find repository at \" + webURI.toString())\n        );\n\n        var commitComments = hostedRepo.recentCommitComments();\n        for (var comment : commitComments) {\n            System.out.println(\"Hash: \" + comment.commit().hex());\n            System.out.println(\"Author: \" + comment.author().username());\n            System.out.println(\"Date: \" + comment.createdAt().format(DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm:ss +0000\")));\n            System.out.println(\"\");\n            System.out.println(comment.body());\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitCredentials.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.util.concurrent.TimeUnit;\n\npublic class GitCredentials {\n    private final String host;\n    private final String path;\n    private final String username;\n    private final String password;\n    private final String protocol;\n\n    public GitCredentials(String host, String path, String username, String password, String protocol) {\n        this.host = host;\n        this.path = path;\n        this.username = username;\n        this.password = password;\n        this.protocol = protocol;\n    }\n\n    public String host() {\n        return host;\n    }\n\n    public String path() {\n        return path;\n    }\n\n    public String username() {\n        return username;\n    }\n\n    public String password() {\n        return password;\n    }\n\n    public String protocol() {\n        return protocol;\n    }\n\n    public static GitCredentials fill(String host, String path, String username, String password, String protocol) throws IOException {\n        try {\n            var pb = new ProcessBuilder(\"git\", \"credential\", \"fill\");\n            pb.redirectInput(ProcessBuilder.Redirect.PIPE);\n            pb.redirectOutput(ProcessBuilder.Redirect.PIPE);\n            pb.redirectError(ProcessBuilder.Redirect.INHERIT);\n            var p = pb.start();\n\n            var gitStdin = p.getOutputStream();\n            String input = \"host=\" + host + \"\\n\";\n            if (path != null) {\n                if (path.startsWith(\"/\")) {\n                    path = path.substring(1);\n                }\n                input += \"path=\" + path + \"\\n\";\n            }\n            if (username != null) {\n                input += \"username=\" + username + \"\\n\";\n            }\n            if (password != null) {\n                input += \"password=\" + password + \"\\n\";\n            }\n            if (protocol != null) {\n                input += \"protocol=\" + protocol + \"\\n\";\n            }\n            gitStdin.write((input + \"\\n\").getBytes(StandardCharsets.UTF_8));\n            gitStdin.flush();\n\n            var bytes = p.getInputStream().readAllBytes();\n            var exited = p.waitFor(10, TimeUnit.MINUTES);\n            var exitValue = p.exitValue();\n            if (!exited || exitValue != 0) {\n                throw new IOException(\"'git credential' exited with value: \" + exitValue);\n            }\n\n            protocol = null;\n            username = null;\n            password = null;\n            path = null;\n            host = null;\n            for (var line : new String(bytes, StandardCharsets.UTF_8).split(\"\\n\")) {\n                if (line.startsWith(\"host=\")) {\n                    host = line.split(\"=\")[1];\n                } else if (line.startsWith(\"username=\")) {\n                    username = line.split(\"=\")[1];\n                } else if (line.startsWith(\"password=\")) {\n                    password = line.split(\"=\")[1];\n                } else if (line.startsWith(\"protocol=\")) {\n                    protocol = line.split(\"=\")[1];\n                } else if (line.startsWith(\"path=\")) {\n                    String[] parts = line.split(\"=\");\n                    path = parts.length > 1 ? parts[1] : null; // value can be empty\n                } else {\n                    throw new IOException(\"'git credential' returned unexpected line: \" + line);\n                }\n            }\n\n            return new GitCredentials(host, path, username, password, protocol);\n        } catch (InterruptedException e) {\n            throw new IOException(e);\n        }\n    }\n\n    public static void approve(GitCredentials credentials) throws IOException {\n        try {\n            var pb = new ProcessBuilder(\"git\", \"credential\", \"approve\");\n            pb.redirectInput(ProcessBuilder.Redirect.PIPE);\n            pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);\n            pb.redirectError(ProcessBuilder.Redirect.INHERIT);\n            var p = pb.start();\n\n            var gitStdin = p.getOutputStream();\n            String input = \"host=\" + credentials.host() + \"\\n\" +\n                           \"path=\" + credentials.path() + \"\\n\" +\n                           \"username=\" + credentials.username() + \"\\n\" +\n                           \"password=\" + credentials.password() + \"\\n\" +\n                           \"protocol=\" + credentials.protocol() + \"\\n\";\n            gitStdin.write((input + \"\\n\").getBytes(StandardCharsets.UTF_8));\n            gitStdin.flush();\n            var res = p.waitFor();\n            if (res != 0) {\n                throw new IOException(\"'git credential approve' exited with value: \" + res);\n            }\n        } catch (InterruptedException e) {\n            throw new IOException(e);\n        }\n    }\n\n    public static void reject(GitCredentials credentials) throws IOException {\n        try {\n            var pb = new ProcessBuilder(\"git\", \"credential\", \"reject\");\n            pb.redirectInput(ProcessBuilder.Redirect.PIPE);\n            pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);\n            pb.redirectError(ProcessBuilder.Redirect.INHERIT);\n            var p = pb.start();\n\n            var gitStdin = p.getOutputStream();\n            String input = \"host=\" + credentials.host() + \"\\n\" +\n                           \"path=\" + credentials.path() + \"\\n\" +\n                           \"username=\" + credentials.username() + \"\\n\" +\n                           \"password=\" + credentials.password() + \"\\n\" +\n                           \"protocol=\" + credentials.protocol() + \"\\n\";\n            gitStdin.write((input + \"\\n\").getBytes(StandardCharsets.UTF_8));\n            gitStdin.flush();\n            var res = p.waitFor();\n            if (res != 0) {\n                throw new IOException(\"'git credential reject' exited with value: \" + res);\n            }\n        } catch (InterruptedException e) {\n            throw new IOException(e);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitDefpath.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.proxy.HttpProxy;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.net.http.*;\nimport static java.net.http.HttpResponse.BodyHandlers;\nimport java.net.URI;\n\npublic class GitDefpath {\n    private static String config(ReadOnlyRepository repo, String key, String fallback) throws IOException {\n        var lines = repo.config(key);\n        if (lines.size() == 0) {\n            return fallback;\n        }\n\n        return lines.get(0);\n    }\n\n    static boolean probe(URI uri) {\n        try {\n            var client = HttpClient.newHttpClient();\n            var req = HttpRequest.newBuilder(uri).build();\n            var res = client.send(req, BodyHandlers.discarding());\n            return res.statusCode() < 400;\n        } catch (InterruptedException e) {\n            // do nothing\n        } catch (IOException e) {\n            // do nothing\n        }\n\n        return false;\n    }\n\n    static String probe(String primary, String fallback) {\n        if (primary.startsWith(\"http\") || primary.startsWith(\"https\")) {\n            var uri = URI.create(primary);\n            if (probe(uri)) {\n                return primary;\n            }\n\n            if (fallback == null) {\n                System.err.println(\"error: repository \" + primary + \" not found\");\n                System.exit(1);\n            }\n\n            if (fallback.startsWith(\"http\") || fallback.startsWith(\"https\")) {\n                var alternative = URI.create(fallback + uri.getPath());\n                if (probe(alternative)) {\n                    return fallback;\n                }\n            }\n\n            System.err.println(\"error: repository \" + primary + \" not found\");\n            System.err.println(\"error: repository \" + fallback + \" not found\");\n            System.exit(1);\n        }\n\n        return primary;\n    }\n\n    static String toPushPath(String pullPath, String username, boolean isMercurial) {\n        if (pullPath.startsWith(\"http\") || pullPath.startsWith(\"https\")) {\n            var uri = URI.create(pullPath);\n            var scheme = uri.getScheme();\n            var user = isMercurial ? username : \"git\";\n            return URI.create(\"ssh://\" + user + \"@\" + uri.getAuthority() + uri.getPath()).toString();\n        }\n\n        return pullPath;\n    }\n\n    static void showPaths(ReadOnlyRepository repo, String remote) throws IOException {\n        showPaths(repo, repo.pullPath(remote), repo.pushPath(remote));\n\n    }\n\n    static void showPaths(ReadOnlyRepository repo, String pull, String push) throws IOException {\n        System.out.format(\"%s:\\n\", repo.root().toString());\n        System.out.format(\"         default = %s\\n\", pull);\n        System.out.format(\"    default-push = %s\\n\", push);\n    }\n\n    static String getUsername(ReadOnlyRepository repo, Arguments arguments) {\n        var arg = arguments.get(\"username\");\n        if (arg.isPresent()) {\n            return arg.asString();\n        }\n\n        try {\n            var lines = repo.config(\"defpath.username\");\n            if (lines.size() == 1) {\n                return lines.get(0);\n            }\n        } catch (IOException e) {\n        }\n\n        try {\n            var conf = repo.username();\n            if (conf.isPresent()) {\n                return conf.get();\n            }\n        } catch (IOException e) {\n        }\n\n        return System.getProperty(\"user.name\");\n    }\n    private static void die(String message) {\n        System.err.println(message);\n        System.exit(1);\n    }\n\n    public static void main(String[] args) throws IOException {\n        var flags = List.of(\n            Option.shortcut(\"u\")\n                  .fullname(\"username\")\n                  .describe(\"NAME\")\n                  .helptext(\"username for push URL\")\n                  .optional(),\n            Option.shortcut(\"r\")\n                  .fullname(\"remote\")\n                  .describe(\"URI\")\n                  .helptext(\"remote for which to set paths\")\n                  .optional(),\n            Option.shortcut(\"s\")\n                  .fullname(\"secondary\")\n                  .describe(\"URL\")\n                  .helptext(\"secondary peer repository base URL\")\n                  .optional(),\n            Switch.shortcut(\"m\")\n                  .fullname(\"mercurial\")\n                  .helptext(\"Deprecated: force use of mercurial\")\n                  .optional(),\n            Switch.shortcut(\"d\")\n                  .fullname(\"default\")\n                  .helptext(\"use current default path to compute push path\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"upstream\")\n                  .helptext(\"create remote 'upstream' for the upstream repository\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"fork\")\n                  .helptext(\"create remote 'fork' for the personal fork of the repository\")\n                  .optional(),\n            Switch.shortcut(\"g\")\n                  .fullname(\"gated\")\n                  .helptext(\"create gated push URL\")\n                  .optional(),\n            Switch.shortcut(\"n\")\n                  .fullname(\"dry-run\")\n                  .helptext(\"do not perform actions, just print output\")\n                  .optional(),\n            Switch.shortcut(\"v\")\n                  .fullname(\"version\")\n                  .helptext(\"Print the version of this tool\")\n                  .optional());\n\n        var inputs = List.of(\n            Input.position(0)\n                 .describe(\"PEER\")\n                 .singular()\n                 .optional(),\n            Input.position(1)\n                 .describe(\"PEER-PUSH\")\n                 .singular()\n                 .optional()\n        );\n\n        var parser = new ArgumentParser(\"git-defpath\", flags, inputs);\n        var arguments = parser.parse(args);\n\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-defpath version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        var cwd = Path.of(\"\").toAbsolutePath();\n        var repository = Repository.get(cwd);\n        if (!repository.isPresent()) {\n            die(String.format(\"error: %s is not a repository\", cwd.toString()));\n        }\n        var repo = repository.get();\n\n        var username = getUsername(repo, arguments);\n        if (username == null) {\n            die(\"error: no username found\");\n        }\n\n        var isMercurial = arguments.contains(\"mercurial\");\n        var remote = ForgeUtils.getOption(\"remote\", arguments);\n        if (remote == null) {\n            var lines = repo.config(\"defpath.remote\");\n            if (lines.size() == 1) {\n                remote = lines.get(0);\n            }\n        }\n        if (remote == null) {\n            remote = isMercurial ? \"default\": \"origin\";\n        }\n\n        if (arguments.contains(\"gated\")) {\n            System.err.println(\"warning: gated push repositories are no longer used, option ignored\");\n        }\n\n        if ((arguments.at(0).isPresent() || arguments.at(1).isPresent()) && arguments.contains(\"default\")) {\n            die(\"error: peers cannot be specified together with -d flag\");\n        }\n\n        var fallback = ForgeUtils.getOption(\"secondary\", arguments);\n        if (fallback == null) {\n            var lines = repo.config(\"defpath.secondary\");\n            if (lines.size() == 1) {\n                fallback = lines.get(0);\n            }\n        }\n\n        HttpProxy.setup();\n\n        String pullPath = null;\n        if (arguments.at(0).isPresent()) {\n            pullPath = arguments.at(0).asString();\n        } else {\n            var useDefault = false;\n            if (arguments.contains(\"default\")) {\n                useDefault = true;\n            } else {\n                var lines = repo.config(\"defpath.default\");\n                useDefault = lines.size() == 1 && lines.get(0).toLowerCase().equals(\"true\");\n            }\n\n            if (useDefault) {\n                try {\n                    pullPath = repo.pullPath(remote);\n                } catch (IOException e) {\n                    die(\"error: -d flag specified but repository has no default path\");\n                }\n            }\n        }\n\n        var dryRun = false;\n        if (arguments.contains(\"dry-run\")) {\n            dryRun = true;\n        } else {\n            var lines = repo.config(\"defpath.dry-run\");\n            dryRun = lines.size() == 1 && lines.get(0).toLowerCase().equals(\"true\");\n        }\n\n        URI upstreamURI = null;\n        URI forkURI = null;\n        var remotes = repo.remotes();\n        if (remotes.contains(\"origin\")) {\n            var setUpstream = arguments.contains(\"upstream\");\n            if (!arguments.contains(\"upstream\")) {\n                var lines = repo.config(\"defpath.upstream\");\n                setUpstream = lines.size() == 1 && lines.get(0).toLowerCase().equals(\"true\");\n            }\n            if (setUpstream) {\n                var originPullPath = repo.pullPath(\"origin\");\n                var uri = Remote.toWebURI(originPullPath);\n                try {\n                    upstreamURI = ForgeUtils.from(uri)\n                                            .flatMap(f -> f.repository(uri.getPath().substring(1)))\n                                            .flatMap(r -> r.parent())\n                                            .map(p -> p.webUrl())\n                                            .orElse(null);\n                } catch (Throwable t) {\n                    System.err.println(\"error: could not find upstream repository\");\n                    System.exit(1);\n                }\n                if (upstreamURI != null && !dryRun) {\n                    if (remotes.contains(\"upstream\")) {\n                        repo.setPaths(\"upstream\", upstreamURI.toString(), upstreamURI.toString());\n                    } else {\n                        repo.addRemote(\"upstream\", upstreamURI.toString());\n                    }\n                }\n            }\n            var setFork = arguments.contains(\"fork\");\n            if (!arguments.contains(\"fork\")) {\n                var lines = repo.config(\"defpath.fork\");\n                setFork = lines.size() == 1 && lines.get(0).toLowerCase().equals(\"true\");\n            }\n            if (setFork) {\n                var originPullPath = repo.pullPath(\"origin\");\n                var uri = Remote.toWebURI(originPullPath);\n                var credentials = GitCredentials.fill(uri.getHost(), uri.getPath(), null, null, uri.getScheme());\n                if (credentials.password() == null) {\n                    System.err.println(\"error: no personal access token found for \" + uri.getHost() + \", use git-credentials\");\n                    System.exit(1);\n                }\n                if (credentials.username() == null) {\n                    System.err.println(\"error: no username for \" + uri.getHost() + \" found, use git-credentials\");\n                    System.exit(1);\n                }\n                try {\n                    forkURI = ForgeUtils.from(uri, new Credential(credentials.username(), credentials.password()))\n                                        .flatMap(f -> f.repository(uri.getPath().substring(1)))\n                                        .map(r -> r.fork())\n                                        .map(fork -> fork.webUrl())\n                                        .orElse(null);\n                } catch (Throwable t) {\n                    System.err.println(\"error: could not find fork for upstream repository\");\n                    System.exit(1);\n                }\n                if (forkURI != null) {\n                    GitCredentials.approve(credentials);\n                    forkURI = URI.create(\"ssh://git@\" + forkURI.getHost() + forkURI.getPath());\n                    if (!dryRun) {\n                        if (remotes.contains(\"fork\")) {\n                            repo.setPaths(\"fork\", forkURI.toString(), forkURI.toString());\n                        } else {\n                            repo.addRemote(\"fork\", forkURI.toString());\n                        }\n                    }\n                }\n\n            }\n        }\n\n        if (pullPath == null) {\n            showPaths(repo, remote);\n            if (upstreamURI != null) {\n                System.out.format(\"        upstream = %s\\n\", upstreamURI.toString());\n            }\n            if (forkURI != null) {\n                System.out.format(\"            fork = %s\\n\", forkURI.toString());\n            }\n            System.exit(0);\n        }\n\n        var newPullPath = probe(pullPath, fallback);\n\n        String pushPath = null;\n        if (arguments.at(1).isPresent()) {\n            pushPath = arguments.at(1).asString();\n        }\n\n        var newPushPath = pushPath == null ? toPushPath(newPullPath, username, isMercurial) : pushPath;\n\n        if (dryRun) {\n            showPaths(repo, newPullPath, newPushPath);\n        } else {\n            repo.setPaths(remote, newPullPath, newPushPath);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitFork.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.forge.Forge;\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.proxy.HttpProxy;\nimport org.openjdk.skara.vcs.Repository;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.net.URI;\nimport java.net.URISyntaxException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.function.Supplier;\nimport java.util.logging.Level;\n\npublic class GitFork {\n    private final Arguments arguments;\n    private final boolean isDryRun;\n    private final String sourceArg;\n\n    public GitFork(Arguments arguments) {\n        this.arguments = arguments;\n        this.isDryRun = arguments.contains(\"dry-run\");\n        this.sourceArg = arguments.at(0).asString();\n    }\n\n    private String gitConfig(String key) {\n        try {\n            var pb = new ProcessBuilder(\"git\", \"config\", key);\n            pb.redirectOutput(ProcessBuilder.Redirect.PIPE);\n            pb.redirectError(ProcessBuilder.Redirect.DISCARD);\n            var p = pb.start();\n\n            var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);\n            var res = p.waitFor();\n            if (res != 0) {\n                return null;\n            }\n\n            return output.replace(\"\\n\", \"\");\n        } catch (InterruptedException | IOException e) {\n            return null;\n        }\n    }\n\n    private String getOption(String name) {\n        return ForgeUtils.getOption(name, \"fork\", sourceArg, arguments);\n    }\n\n    private boolean getSwitch(String name) {\n        var option = getOption(name);\n        return option != null && option.equalsIgnoreCase(\"true\");\n    }\n\n    private URI getURIFromArgs() {\n        var hostname = getOption(\"host\");\n\n        try {\n            if (hostname != null) {\n                // Assume command line argument is just the path component\n                var extraSlash = sourceArg.startsWith(\"/\") ? \"\" : \"/\";\n                return new URI(\"https://\" + hostname + extraSlash + sourceArg);\n            } else {\n                var uri = new URI(sourceArg);\n                if (uri.getScheme() == null) {\n                    return new URI(\"https://\" + uri.getHost() + uri.getPath());\n                } else {\n                    return uri;\n                }\n            }\n        } catch (URISyntaxException e) {\n            exit(\"error: could not form a valid URI from argument: \" + sourceArg);\n            return null; // make compiler quiet\n        }\n    }\n\n    private Path getTargetDir(URI cloneURI) {\n        if (arguments.at(1).isPresent()) {\n            // If user provided an explicit name for target dir, use it\n            return Path.of(arguments.at(1).asString());\n        } else {\n            // Otherwise get the base name from the URI\n            var targetDir = Path.of(cloneURI.getPath()).getFileName();\n            var targetDirStr = targetDir.toString();\n\n            if (targetDirStr.endsWith(\".git\")) {\n                return Path.of(targetDirStr.substring(0, targetDirStr.length() - \".git\".length()));\n            } else {\n                return targetDir;\n            }\n        }\n    }\n\n    private Repository clone(List<String> args, URI cloneURI, Path targetDir) throws IOException {\n        try {\n            var command = new ArrayList<String>();\n            command.add(\"git\");\n            command.add(\"clone\");\n            command.addAll(args);\n            command.add(cloneURI.toString());\n            command.add(targetDir.toString());\n            if (!isDryRun) {\n                var pb = new ProcessBuilder(command);\n                pb.inheritIO();\n                var p = pb.start();\n                var res = p.waitFor();\n                if (res != 0) {\n                    exit(\"error: '\" + \"git\" + \" clone \" + String.join(\" \", args) + \"' failed with exit code: \" + res);\n                }\n            }\n            return Repository.get(targetDir).orElseThrow(() -> new IOException(\"Could not find repository\"));\n        } catch (InterruptedException e) {\n            throw new IOException(e);\n        }\n    }\n\n    public void fork() throws IOException, InterruptedException {\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-fork version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            Logging.setup(level);\n        }\n\n        if (isDryRun) {\n            System.out.println(\"Running in dry-run mode. No actual changes will be performed\");\n        }\n\n        HttpProxy.setup();\n\n        // Get the upstream repo user specified on the command line\n        var upstreamURI = getURIFromArgs();\n        var upstreamWebURI = Remote.toWebURI(upstreamURI.toString());\n        System.out.println(\"Creating fork of \" + upstreamWebURI);\n        var credentials = setupCredentials(upstreamWebURI);\n\n        // Use Forge::from directly instead of ForgeUtils::from. ForgeUtils would\n        // try to update \"forge.name\" in the local repository based on this forge,\n        // and it's unlikely they are related.\n        var gitForge = Forge.from(upstreamWebURI, credentials);\n        if (gitForge.isEmpty()) {\n            exit(\"error: could not connect to host \" + upstreamWebURI.getHost());\n        }\n\n        var repositoryPath = getTrimmedPath(upstreamWebURI);\n        var upstreamHostedRepo = gitForge.get().repository(repositoryPath).orElseThrow(() ->\n            new IOException(\"Could not find repository at \" + upstreamWebURI)\n        );\n\n        // Create personal fork (\"origin\" from now on) at Git Forge\n        var originHostedRepo = upstreamHostedRepo.fork();\n        var originWebURI = originHostedRepo.webUrl();\n        System.out.println(\"Personal fork available at \" + originWebURI);\n\n        if (getSwitch(\"no-clone\")) {\n            // We're done here, if we should not create a local clone\n            logVerbose(\"Not cloning fork due to --no-clone\");\n            return;\n        }\n\n        // Create a local clone\n        var cloneURI = getCloneURI(originWebURI);\n        System.out.println(\"Cloning personal fork...\");\n        var repo = clone(getCloneArgs(), cloneURI, getTargetDir(cloneURI));\n        System.out.println(\"Done cloning\");\n\n        // Setup git remote\n        if (!getSwitch(\"no-remote\")) {\n            System.out.println(\"Adding remote 'upstream' for \" + upstreamWebURI);\n            if (!isDryRun) {\n                repo.addRemote(\"upstream\", upstreamWebURI.toString());\n            }\n        }\n\n        // Sync the fork from upstream\n        if (getSwitch(\"sync\")) {\n            logVerbose(\"Syncing personal fork with upstream\");\n            var syncArgs = new ArrayList<String>();\n            syncArgs.add(\"--fast-forward\");\n            if (getSwitch(\"no-remote\")) {\n                // Propagate --no-remote; and also specify the remote for git sync to work\n                syncArgs.add(\"--no-remote\");\n                syncArgs.add(\"--from\");\n                syncArgs.add(upstreamWebURI.toString());\n            }\n            if (!isDryRun) {\n                GitSync.sync(repo, syncArgs.toArray(new String[] {}));\n            }\n        }\n\n        // Setup jcheck hooks\n        if (getSwitch(\"setup-pre-push-hook\")) {\n            logVerbose(\"Setting up jcheck hooks\");\n            if (!isDryRun) {\n                var res = GitJCheck.run(repo, new String[] {\"--setup-pre-push-hook\"});\n                if (res != 0) {\n                    System.exit(res);\n                }\n            }\n        }\n    }\n\n    private Credential setupCredentials(URI upstreamWebURI) throws IOException {\n        var token = System.getenv(\"GIT_TOKEN\");\n        var username = getOption(\"username\");\n\n        var credentials = GitCredentials.fill(upstreamWebURI.getHost(), upstreamWebURI.getPath(), username, token, upstreamWebURI.getScheme());\n\n        if (credentials.password() == null) {\n            exit(\"error: no personal access token found, use git-credentials or the environment variable GIT_TOKEN\");\n        }\n        if (credentials.username() == null) {\n            exit(\"error: no username for \" + upstreamWebURI.getHost() + \" found, use git-credentials or the flag --username\");\n        }\n        if (token == null) {\n            GitCredentials.approve(credentials);\n        }\n        return new Credential(credentials.username(), credentials.password());\n    }\n\n    private URI getCloneURI(URI originWebURI) {\n        if (getSwitch(\"ssh\")) {\n            return URI.create(\"ssh://git@\" + originWebURI.getHost() + originWebURI.getPath() + \".git\");\n        } else {\n            return originWebURI;\n        }\n    }\n\n    private ArrayList<String> getCloneArgs() {\n        var cloneArgs = new ArrayList<String>();\n\n        var reference = getOption(\"reference\");\n        if (reference != null) {\n            cloneArgs.add(\"--reference-if-able=\" + expandPath(reference));\n            cloneArgs.add(\"--dissociate\");\n        }\n\n        var depth = getOption(\"depth\");\n        if (depth != null) {\n            cloneArgs.add(\"--depth=\" + depth);\n        }\n\n        var shallowSince = getOption(\"shallow-since\");\n        if (shallowSince != null) {\n            cloneArgs.add(\"--shallow-since=\" + shallowSince);\n        }\n\n        return cloneArgs;\n    }\n\n    private static String expandPath(String path) {\n        // FIXME: Why is this not done from the shell? It should not be needed.\n        if (path.startsWith(\"~\" + File.separator)) {\n            return System.getProperty(\"user.home\") + path.substring(1);\n        } else {\n            return path;\n        }\n    }\n\n    private static String getTrimmedPath(URI uri) {\n        var repositoryPath = uri.getPath().substring(1);\n\n        if (repositoryPath.endsWith(\"/\")) {\n            return repositoryPath.substring(0, repositoryPath.length() - 1);\n        } else {\n            return repositoryPath;\n        }\n    }\n\n    private void logVerbose(String message) {\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            System.out.println(message);\n        }\n    }\n\n    private static void exit(String message) {\n        System.err.println(message);\n        System.exit(1);\n    }\n\n    private static <T> Supplier<T> die(String message) {\n        return () -> {\n            exit(message);\n            return null;\n        };\n    }\n\n    private static Arguments parseArguments(String[] args) {\n        var flags = List.of(\n                Option.shortcut(\"u\")\n                        .fullname(\"username\")\n                        .describe(\"NAME\")\n                        .helptext(\"Username on host\")\n                        .optional(),\n                Option.shortcut(\"\")\n                        .fullname(\"reference\")\n                        .describe(\"DIR\")\n                        .helptext(\"Same as the 'git clone' flags 'reference-if-able' + 'dissociate'\")\n                        .optional(),\n                Option.shortcut(\"\")\n                        .fullname(\"depth\")\n                        .describe(\"N\")\n                        .helptext(\"Same as the 'git clone' flag 'depth'\")\n                        .optional(),\n                Option.shortcut(\"\")\n                        .fullname(\"shallow-since\")\n                        .describe(\"DATE\")\n                        .helptext(\"Same as the 'git clone' flag 'shallow-since'\")\n                        .optional(),\n                Switch.shortcut(\"\")\n                        .fullname(\"setup-pre-push-hook\")\n                        .helptext(\"Setup a pre-push hook that runs git-jcheck\")\n                        .optional(),\n                Option.shortcut(\"\")\n                        .fullname(\"host\")\n                        .describe(\"HOSTNAME\")\n                        .helptext(\"Hostname for the forge\")\n                        .optional(),\n                Switch.shortcut(\"\")\n                        .fullname(\"no-clone\")\n                        .helptext(\"Just fork the repository, do not clone it\")\n                        .optional(),\n                Switch.shortcut(\"\")\n                        .fullname(\"no-remote\")\n                        .helptext(\"Do not add an upstream git remote\")\n                        .optional(),\n                Switch.shortcut(\"\")\n                        .fullname(\"ssh\")\n                        .helptext(\"Use the ssh:// protocol when cloning (instead of https)\")\n                        .optional(),\n                Switch.shortcut(\"\")\n                        .fullname(\"sync\")\n                        .helptext(\"Sync with the upstream repository after successful fork\")\n                        .optional(),\n                Switch.shortcut(\"n\")\n                        .fullname(\"dry-run\")\n                        .helptext(\"Only simulate behavior, do no actual changes\")\n                        .optional(),\n                Switch.shortcut(\"\")\n                        .fullname(\"verbose\")\n                        .helptext(\"Turn on verbose output\")\n                        .optional(),\n                Switch.shortcut(\"\")\n                        .fullname(\"debug\")\n                        .helptext(\"Turn on debugging output\")\n                        .optional(),\n                Switch.shortcut(\"\")\n                        .fullname(\"version\")\n                        .helptext(\"Print the version of this tool\")\n                        .optional());\n\n        var inputs = List.of(\n                Input.position(0)\n                        .describe(\"URI\")\n                        .singular()\n                        .required(),\n                Input.position(1)\n                        .describe(\"NAME\")\n                        .singular()\n                        .optional());\n\n        var parser = new ArgumentParser(\"git fork\", flags, inputs);\n        return parser.parse(args);\n    }\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        GitFork commandExecutor = new GitFork(parseArguments(args));\n        commandExecutor.fork();\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitHgExport.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.jcheck.JCheckConfiguration;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.*;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.logging.Level;\nimport java.time.format.DateTimeFormatter;\n\npublic class GitHgExport {\n    private static void die(String msg) {\n        System.err.println(\"error: \" + msg);\n        System.exit(1);\n    }\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var flags = List.of(\n            Switch.shortcut(\"\")\n                  .fullname(\"verbose\")\n                  .helptext(\"Turn on verbose output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"debug\")\n                  .helptext(\"Turn on debugging output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"version\")\n                  .helptext(\"Print the version of this tool\")\n                  .optional());\n\n        var inputs = List.of(\n            Input.position(0)\n                 .describe(\"REV\")\n                 .singular()\n                 .required()\n        );\n\n        var parser = new ArgumentParser(\"git-hg-export\", flags, inputs);\n        var arguments = parser.parse(args);\n\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-hg-export version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            Logging.setup(level);\n        }\n\n        var ref = arguments.at(0).orString(\"HEAD\");\n        var cwd = Path.of(\"\").toAbsolutePath();\n        var repo = ReadOnlyRepository.get(cwd);\n        if (repo.isEmpty()) {\n            die(\"no repository present at: \" + cwd);\n        }\n        var hash = repo.get().resolve(ref);\n        if (hash.isEmpty()) {\n            die(ref + \" does not refer to a commit\");\n        }\n        var commit = repo.get().lookup(hash.get());\n        if (commit.isEmpty()) {\n            die(\"internal error - could not lookup \" + hash.get());\n        }\n\n        var c = commit.get();\n        var committer = c.committer();\n        String username = null;\n        if (committer.email().endsWith(\"@openjdk.org\")) {\n            username = committer.email().split(\"@\")[0];\n        } else {\n            var jcheckConf = JCheckConfiguration.from(repo.get(), repo.get().head());\n            if (jcheckConf.isEmpty()) {\n                die(\"No .jcheck/conf file found\");\n            }\n            var censusURI = jcheckConf.get().census().url();\n            var census = Census.from(censusURI);\n            var namespace = census.namespace(\"openjdk.org\");\n            if (namespace == null) {\n                die(\"No namespace found for openjdk.org\");\n            }\n            var localUsername = committer.email().split(\"@\")[0];\n            username = namespace.get(localUsername).username();\n            if (username == null) {\n                die(\"No census name found for \" + localUsername);\n            }\n        }\n        var date = c.committed();\n        var dateFormatter = DateTimeFormatter.ofPattern(\"EE MMM HH:mm:ss yyyy xx\");\n\n        System.out.println(\"# HG changeset patch\");\n        System.out.println(\"# User \" + username);\n        System.out.println(\"# Date \" + date.toEpochSecond() + \" \" + (-1 * date.getOffset().getTotalSeconds()));\n        System.out.println(\"#      \" + date.format(dateFormatter));\n\n        var message = CommitMessageParsers.v1.parse(c);\n        if (!c.author().equals(committer)) {\n            message.addContributor(c.author());\n        }\n        for (var line : CommitMessageFormatters.v0.format(message)) {\n            System.out.println(line);\n        }\n        System.out.println(\"\");\n        var pb = new ProcessBuilder(\"git\", \"diff\", \"--patch\",\n                                                   \"--binary\",\n                                                   \"--no-color\",\n                                                   \"--find-renames=99%\",\n                                                   \"--find-copies=99%\",\n                                                   \"--find-copies-harder\",\n                                                   repo.get().range(c.hash()));\n        pb.inheritIO();\n        System.exit(pb.start().waitFor());\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitInfo.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.issuetracker.IssueTracker;\nimport org.openjdk.skara.jcheck.*;\nimport org.openjdk.skara.vcs.openjdk.*;\nimport org.openjdk.skara.proxy.HttpProxy;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.function.Supplier;\nimport java.util.logging.Level;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class GitInfo {\n    private static final URI JBS = URI.create(\"https://bugs.openjdk.org\");\n\n    private static void exit(String fmt, Object...args) {\n        System.err.println(String.format(fmt, args));\n        System.exit(1);\n    }\n\n    private static Supplier<IOException> die(String fmt, Object... args) {\n        return () -> {\n            exit(fmt, args);\n            return new IOException();\n        };\n    }\n\n    private static boolean getSwitch(String name, Arguments arguments, ReadOnlyRepository repo) throws IOException {\n        if (arguments.contains(name)) {\n            return true;\n        }\n\n        var lines = repo.config(\"info.\" + name);\n        return lines.size() == 1 && lines.get(0).toLowerCase().equals(\"true\");\n    }\n\n    private static String jbsProject(ReadOnlyRepository repo, Hash hash) throws IOException {\n        var conf = JCheckConfiguration.from(repo, hash).orElseThrow();\n        var jbsProject = conf.general().jbs();\n        if (jbsProject != null) {\n            return jbsProject.toUpperCase();\n        } else {\n            return null;\n        }\n    }\n\n    private static URI getReviewUrl(ReadOnlyRepository repo, Arguments arguments, Hash hash, CommitMessage message) throws IOException {\n        var repoUrl = ForgeUtils.getURI(repo, \"info\", arguments);\n        var forge = ForgeUtils.getForge(repoUrl, repo, \"info\", arguments);\n        var remoteRepo = ForgeUtils.getHostedRepositoryFor(repoUrl, repo, forge);\n\n        return remoteRepo.reviewUrl(hash);\n    }\n\n    public static void main(String[] args) throws IOException {\n        var flags = List.of(\n            Switch.shortcut(\"\")\n                  .fullname(\"no-decoration\")\n                  .helptext(\"Do not prefix lines with any decoration\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"title\")\n                  .helptext(\"Show title of commit message\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"issues\")\n                  .helptext(\"Show link(s) to issue(s)\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"reviewers\")\n                  .helptext(\"Show reviewers\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"review\")\n                  .helptext(\"Show link to review\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"summary\")\n                  .helptext(\"Show summary (if present)\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"sponsor\")\n                  .helptext(\"Show sponsor (if present)\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"author\")\n                  .helptext(\"Show author\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"contributors\")\n                  .helptext(\"Show contributors\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"upstream\")\n                  .helptext(\"Show upstream commit hash\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"verbose\")\n                  .helptext(\"Turn on verbose output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"debug\")\n                  .helptext(\"Turn on debugging output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"version\")\n                  .helptext(\"Print the version of this tool\")\n                  .optional());\n\n        var inputs = List.of(\n            Input.position(0)\n                 .describe(\"REV\")\n                 .singular()\n                 .required()\n        );\n\n        var parser = new ArgumentParser(\"git-info\", flags, inputs);\n        var arguments = parser.parse(args);\n\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-info version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            Logging.setup(level);\n        }\n\n        HttpProxy.setup();\n        var ref = arguments.at(0).orString(\"HEAD\");\n        var cwd = Path.of(\"\").toAbsolutePath();\n        var repo = ReadOnlyRepository.get(cwd).orElseThrow(die(\"error: no repository found at \" + cwd.toString()));\n        var hash = repo.resolve(ref).orElseThrow(die(\"error: \" + ref + \" is not a commit\"));\n        var commits = repo.commits(hash.hex(), 1).asList();\n        if (commits.isEmpty()) {\n            throw new IOException(\"internal error: could not list commit for \" + hash.hex());\n        }\n        var commit = commits.get(0);\n\n        var useDecoration = !getSwitch(\"no-decoration\", arguments, repo);\n        var useMercurial = getSwitch(\"mercurial\", arguments, repo);\n\n        var showSponsor = getSwitch(\"sponsor\", arguments, repo);\n        var showAuthors = getSwitch(\"authors\", arguments, repo);\n        var showReviewers = getSwitch(\"reviewers\", arguments, repo);\n        var showReview = getSwitch(\"review\", arguments, repo);\n        var showSummary = getSwitch(\"summary\", arguments, repo);\n        var showIssues = getSwitch(\"issues\", arguments, repo);\n        var showTitle = getSwitch(\"title\", arguments, repo);\n        var showUpstream = getSwitch(\"upstream\", arguments, repo);\n\n        if (!showSponsor && !showAuthors && !showReviewers &&\n            !showReview && !showSummary && !showIssues && !showTitle && !showUpstream) {\n            // no switches or configuration provided, show everything by default\n            showSponsor = true;\n            showAuthors = true;\n            showReviewers = true;\n            showReview = true;\n            showSummary = true;\n            showIssues = true;\n            showTitle = true;\n            showUpstream = true;\n        }\n\n        var message = useMercurial ?\n            CommitMessageParsers.v0.parse(commit) :\n            CommitMessageParsers.v1.parse(commit);\n\n        if (showTitle) {\n            var decoration = useDecoration ? \"Title: \" : \"\";\n            System.out.println(decoration + message.title());\n        }\n\n        if (showSummary) {\n            if (useDecoration && !message.summaries().isEmpty()) {\n                System.out.println(\"Summary:\");\n            }\n            var decoration = useDecoration ? \"> \" : \"\";\n            for (var line : message.summaries()) {\n                System.out.println(decoration + line);\n            }\n        }\n\n        if (showAuthors) {\n            var decoration = \"\";\n            if (useDecoration) {\n                decoration = message.contributors().isEmpty() ?\n                    \"Author: \" : \"Authors: \";\n            }\n            var authors = commit.author().toString();\n            if (!message.contributors().isEmpty()) {\n                var contributorNames = message.contributors()\n                                              .stream()\n                                              .map(Author::toString)\n                                              .collect(Collectors.toList());\n                authors += \", \" + String.join(\", \", contributorNames);\n            }\n            System.out.println(decoration + authors);\n        }\n\n        if (showSponsor) {\n            if (!commit.author().equals(commit.committer())) {\n                var decoration = useDecoration ? \"Sponsor: \" : \"\";\n                System.out.println(decoration + commit.committer().toString());\n            }\n        }\n\n        if (showReviewers) {\n            var decoration = \"\";\n            if (useDecoration) {\n                decoration = message.reviewers().size() > 1 ?\n                    \"Reviewers: \" : \"Reviewer: \";\n            }\n            System.out.println(decoration + String.join(\", \", message.reviewers()));\n        }\n\n        if (showUpstream) {\n            if (message.original().isPresent()) {\n                var decoration = useDecoration ? \"Upstream: \" : \"\";\n                System.out.println(decoration + message.original().get().hex());\n            }\n        }\n\n        if (showReview) {\n            var reviewUrl = getReviewUrl(repo, arguments, hash, message);\n            if (reviewUrl != null) {\n                var decoration = useDecoration? \"Review: \" : \"\";\n                System.out.println(decoration + reviewUrl);\n            }\n        }\n        if (showIssues) {\n            var project = jbsProject(repo, hash);\n            if (project != null) {\n                var uri = JBS + \"/browse/\" + project + \"-\";\n                var issues = message.issues();\n                if (issues.size() > 1) {\n                    if (useDecoration) {\n                        System.out.println(\"Issues:\");\n                    }\n                    var decoration = useDecoration ? \"- \" : \"\";\n                    for (var issue : issues) {\n                        System.out.println(decoration + uri + issue.shortId());\n                    }\n                } else if (issues.size() == 1) {\n                    var decoration = useDecoration ? \"Issue: \" : \"\";\n                    System.out.println(decoration + uri + issues.get(0).shortId());\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitJCheck.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.jcheck.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.proxy.HttpProxy;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.*;\nimport java.net.*;\nimport java.nio.file.*;\nimport java.nio.file.attribute.PosixFilePermissions;\nimport java.nio.charset.StandardCharsets;\nimport java.util.*;\nimport java.util.stream.Collectors;\nimport java.util.logging.Level;\n\nimport static org.openjdk.skara.jcheck.JCheck.STAGED_REV;\nimport static org.openjdk.skara.jcheck.JCheck.WORKING_TREE_REV;\n\npublic class GitJCheck {\n    static String gitConfig(String key) {\n        try {\n            var pb = new ProcessBuilder(\"git\", \"config\", key);\n            pb.redirectOutput(ProcessBuilder.Redirect.PIPE);\n            pb.redirectError(ProcessBuilder.Redirect.DISCARD);\n            var p = pb.start();\n\n            var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);\n            var res = p.waitFor();\n            if (res != 0) {\n                return null;\n            }\n\n            return output == null ? null : output.replace(\"\\n\", \"\");\n        } catch (InterruptedException e) {\n            return null;\n        } catch (IOException e) {\n            return null;\n        }\n    }\n\n    static String getOption(String name, Arguments arguments) {\n        return ForgeUtils.getOption(name, \"jcheck\", null, arguments);\n    }\n\n    static boolean getSwitch(String name, Arguments arguments) {\n        if (arguments.contains(name)) {\n            return true;\n        }\n        var value = gitConfig(\"jcheck.\" + name);\n        return value != null && value.toLowerCase().equals(\"true\");\n    }\n\n    public static int run(Repository repo, String[] args) throws IOException {\n        var flags = List.of(\n            Option.shortcut(\"r\")\n                  .fullname(\"rev\")\n                  .describe(\"REV\")\n                  .helptext(\"Check the specified revision or range (default: HEAD)\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"census\")\n                  .describe(\"FILE\")\n                  .helptext(\"Use the specified census (default: https://openjdk.org/census.xml)\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"ignore\")\n                  .describe(\"CHECKS\")\n                  .helptext(\"Ignore errors from checks with the given name\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"conf-rev\")\n                  .describe(\"REV\")\n                  .helptext(\"Use .jcheck/conf in the specified revision\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"conf-file\")\n                  .describe(\"FILE\")\n                  .helptext(\"Use this file as jcheck configuration instead of .jcheck/conf\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"setup-pre-push-hook\")\n                  .helptext(\"Set up a pre-push hook that runs jcheck on commits to be pushed\")\n                  .optional(),\n            Switch.shortcut(\"m\")\n                  .fullname(\"mercurial\")\n                  .helptext(\"Deprecated: force use of mercurial\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"verbose\")\n                  .helptext(\"Turn on verbose output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"debug\")\n                  .helptext(\"Turn on debugging output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"lax\")\n                  .helptext(\"Check comments, tags and whitespace laxly\")\n                  .optional(),\n            Switch.shortcut(\"s\")\n                  .fullname(\"strict\")\n                  .helptext(\"Check everything\")\n                  .optional(),\n            Switch.shortcut(\"v\")\n                  .fullname(\"version\")\n                  .helptext(\"Print the version of this tool\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"conf-staged\")\n                  .helptext(\"Use staged .jcheck/conf\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"conf-working-tree\")\n                  .helptext(\"Use .jcheck/conf in current working tree\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"staged\")\n                  .helptext(\"Check staged changes as if they were committed\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"working-tree\")\n                  .helptext(\"Check changes in working tree as if they were committed\")\n                  .optional()\n        );\n\n        var parser = new ArgumentParser(\"git jcheck\", flags, List.of());\n        var arguments = parser.parse(args);\n\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-jcheck version: \" + Version.fromManifest().orElse(\"unknown\"));\n            return 0;\n        }\n\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            Logging.setup(level, \"jcheck\");\n        }\n\n        HttpProxy.setup();\n\n        var isMercurial = getSwitch(\"mercurial\", arguments);\n        var setupPrePushHook = getSwitch(\"setup-pre-push-hook\", arguments);\n        if (!isMercurial && setupPrePushHook) {\n            var hookFile = repo.root().resolve(\".git\").resolve(\"hooks\").resolve(\"pre-push\");\n            Files.createDirectories(hookFile.getParent());\n            var lines = List.of(\n                \"#!/usr/bin/sh\",\n                \"JCHECK_IS_PRE_PUSH_HOOK=1 git jcheck\"\n            );\n            Files.write(hookFile, lines);\n            if (hookFile.getFileSystem().supportedFileAttributeViews().contains(\"posix\")) {\n                var permissions = PosixFilePermissions.fromString(\"rwxr-xr-x\");\n                Files.setPosixFilePermissions(hookFile, permissions);\n            }\n            return 0;\n        }\n\n        var isPrePush = System.getenv().containsKey(\"JCHECK_IS_PRE_PUSH_HOOK\");\n        var ranges = new ArrayList<String>();\n        var targetBranches = new HashSet<String>();\n        if (!isMercurial && isPrePush) {\n            var reader = new BufferedReader(new InputStreamReader(System.in));\n            var lines = reader.lines().collect(Collectors.toList());\n            for (var line : lines) {\n                var parts = line.split(\" \");\n                var localHash = new Hash(parts[1]);\n                var remoteRef = parts[2];\n                var remoteHash = new Hash(parts[3]);\n\n                if (localHash.equals(Hash.zero())) {\n                    continue;\n                }\n\n                if (remoteHash.equals(Hash.zero())) {\n                    ranges.add(\"origin..\" + localHash.hex());\n                } else {\n                    targetBranches.add(Path.of(remoteRef).getFileName().toString());\n                    ranges.add(remoteHash.hex() + \"..\" + localHash.hex());\n                }\n            }\n        } else {\n            var defaultRange = isMercurial ? \"tip\" : \"HEAD^..HEAD\";\n            ranges.add(arguments.get(\"rev\").orString(defaultRange));\n        }\n\n        for (var range : ranges) {\n            if (!repo.isValidRevisionRange(range)) {\n                System.err.println(String.format(\"error: %s is not a valid range of revisions,\", range));\n                if (isMercurial) {\n                    System.err.println(\"       see 'hg help revisions' for how to specify revisions\");\n                } else {\n                    System.err.println(\"       see 'man 7 gitrevisions' for how to specify revisions\");\n                }\n                return 1;\n            }\n        }\n\n        var endpoint = getOption(\"census\", arguments);\n        var census = endpoint == null ? null :\n            endpoint.startsWith(\"http://\") || endpoint.startsWith(\"https://\") ?\n                Census.from(URI.create(endpoint)) : Census.parse(Path.of(endpoint));\n\n        var ignore = new HashSet<String>();\n        var ignoreOption = getOption(\"ignore\", arguments);\n        if (ignoreOption != null) {\n            for (var check : ignoreOption.split(\",\")) {\n                ignore.add(check.trim());\n            }\n        }\n\n        var revFlag = arguments.contains(\"rev\");\n        var staged = arguments.contains(\"staged\");\n        var workingTree = arguments.contains(\"working-tree\");\n        int flagCount = 0;\n        if (revFlag) {\n            flagCount++;\n        }\n        if (staged) {\n            flagCount++;\n        }\n        if (workingTree) {\n            flagCount++;\n        }\n        // These three flags are mutually exclusive\n        if (flagCount > 1) {\n            System.err.println(String.format(\"error: can only use one of --staged, --working-tree or --rev\"));\n            return 1;\n        }\n\n        var confRev = arguments.contains(\"conf-rev\");\n        var confStaged = arguments.contains(\"conf-staged\");\n        var confWorkingTree = arguments.contains(\"conf-working-tree\");\n        var confFile = arguments.contains(\"conf-file\");\n\n        int confFlagCount = 0;\n        if (confRev) {\n            confFlagCount++;\n        }\n        if (confStaged) {\n            confFlagCount++;\n        }\n        if (confWorkingTree) {\n            confFlagCount++;\n        }\n        if (confFile) {\n            confFlagCount++;\n        }\n        // These four flags are mutually exclusive\n        if (confFlagCount > 1) {\n            System.err.println(String.format(\"error: can only use one source for jcheck configuration\"));\n            return 1;\n        }\n        JCheckConfiguration overridingConfig = null;\n        // Using jcheck configuration in a specified rev\n        if (confRev) {\n            var rev = ForgeUtils.getOption(\"conf-rev\", arguments);\n            var confCommitHash = repo.resolve(rev);\n            if (confCommitHash.isEmpty()) {\n                System.err.println(String.format(\"error: rev %s is invalid!\", rev));\n                return 1;\n            }\n            try {\n                overridingConfig = JCheck.parseConfiguration(repo, confCommitHash.get(), List.of()).get();\n            } catch (IllegalArgumentException e) {\n                System.err.println(String.format(\"error: Invalid jcheck configuration: %s\", e.getMessage()));\n                return 1;\n            }\n        }\n        // Using staged jcheck configuration\n        else if (confStaged || (staged && !confFile && !confWorkingTree)) {\n            var content = repo.stagedFileContents(Path.of(\".jcheck/conf\"));\n            if (content.isEmpty()) {\n                System.err.println(String.format(\"error: .jcheck/conf doesn't exist!\"));\n                return 1;\n            }\n            try {\n                overridingConfig = JCheck.parseConfiguration(content.get(), List.of()).get();\n            } catch (IllegalArgumentException e) {\n                System.err.println(String.format(\"error: Invalid jcheck configuration: %s\", e.getMessage()));\n                return 1;\n            }\n        }\n        // Using pointed file as jcheck configuration or jcheck configuration in current working tree\n        else if (confFile || confWorkingTree || workingTree) {\n            var fileName = ForgeUtils.getOption(\"conf-file\", arguments, \".jcheck/conf\");\n            try {\n                var content = Files.readAllBytes(Path.of(fileName));\n                var lines = new String(content, StandardCharsets.UTF_8).lines().toList();\n                overridingConfig = JCheck.parseConfiguration(lines, List.of()).get();\n            } catch (NoSuchFileException e) {\n                System.err.println(String.format(\"error: File %s doesn't exist!\", fileName));\n                return 1;\n            } catch (IllegalArgumentException e) {\n                System.err.println(String.format(\"error: Invalid jcheck configuration: %s,\", e.getMessage()));\n                return 1;\n            }\n        }\n\n        if (staged) {\n            ranges.clear();\n            ranges.add(STAGED_REV);\n            System.out.println(\"When jcheck is running on staged, only the following commit checks are available: \" +\n                    JCheck.commitCheckNamesForStagedOrWorkingTree());\n        }\n        if (workingTree) {\n            ranges.clear();\n            ranges.add(WORKING_TREE_REV);\n            System.out.println(\"When jcheck is running on working-tree, only the following commit checks are available: \" +\n                    JCheck.commitCheckNamesForStagedOrWorkingTree());\n        }\n\n        var isLax = getSwitch(\"lax\", arguments);\n        var visitor = new JCheckCLIVisitor(ignore, isMercurial, isLax);\n        var commitMessageParser = isMercurial ? CommitMessageParsers.v0 : CommitMessageParsers.v1;\n        for (var range : ranges) {\n            try (var errors = JCheck.check(repo, census, commitMessageParser, range, overridingConfig)) {\n                for (var error : errors) {\n                    error.accept(visitor);\n                }\n            } catch (Exception e) {\n                System.err.println(String.format(\"error: exception thrown during jcheck: %s\", e.getMessage()));\n                if (e.getCause() instanceof ConnectException) {\n                    System.err.println(\"If you are behind a firewall without direct access to the internet, make sure to configure any required proxy server through the https_proxy environment variable and try again\");\n                }\n                return 1;\n            }\n        }\n        return visitor.hasDisplayedErrors() ? 1 : 0;\n    }\n\n    public static void main(String[] args) throws IOException {\n        var cwd = Paths.get(\"\").toAbsolutePath();\n        var repository = Repository.get(cwd);\n        if (!repository.isPresent()) {\n            System.err.println(String.format(\"error: %s is not a repository\", cwd.toString()));\n            System.exit(1);\n        }\n\n        System.exit(run(repository.get(), args));\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitPr.java",
    "content": "/*\n * Copyright (c) 2019, 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.cli.pr.*;\nimport org.openjdk.skara.proxy.HttpProxy;\n\nimport java.util.List;\nimport java.util.Optional;\n\npublic class GitPr {\n    private static final List<Command> commands = List.of(\n            Default.name(\"help\")\n                    .helptext(\"show help text\")\n                    .main(GitPrHelp::main),\n            Command.name(\"list\")\n                    .helptext(\"list open pull requests\")\n                    .main(GitPrList::main),\n            Command.name(\"fetch\")\n                    .helptext(\"fetch a pull request\")\n                    .main(GitPrFetch::main),\n            Command.name(\"show\")\n                    .helptext(\"show a pull request\")\n                    .main(GitPrShow::main),\n            Command.name(\"checkout\")\n                    .helptext(\"checkout a pull request\")\n                    .main(GitPrCheckout::main),\n            Command.name(\"apply\")\n                    .helptext(\"apply a pull request\")\n                    .main(GitPrApply::main),\n            Command.name(\"integrate\")\n                    .helptext(\"integrate a pull request\")\n                    .main(GitPrIntegrate::main),\n            Command.name(\"review\")\n                    .helptext(\"review a pull request\")\n                    .main(GitPrReview::main),\n            Command.name(\"create\")\n                    .helptext(\"create a pull request\")\n                    .main(GitPrCreate::main),\n            Command.name(\"close\")\n                    .helptext(\"close a pull request\")\n                    .main(GitPrClose::main),\n            Command.name(\"set\")\n                    .helptext(\"set properties of a pull request\")\n                    .main(GitPrSet::main),\n            Command.name(\"sponsor\")\n                    .helptext(\"sponsor a pull request\")\n                    .main(GitPrSponsor::main),\n            Command.name(\"test\")\n                    .helptext(\"test a pull request\")\n                    .main(GitPrTest::main),\n            Command.name(\"info\")\n                    .helptext(\"show status of a pull request\")\n                    .main(GitPrInfo::main),\n            Command.name(\"issue\")\n                    .helptext(\"add, remove or create issues\")\n                    .main(GitPrIssue::main),\n            Command.name(\"reviewer\")\n                    .helptext(\"add or remove reviewers\")\n                    .main(GitPrReviewer::main),\n            Command.name(\"summary\")\n                    .helptext(\"add a summary to the commit message for the pull request\")\n                    .main(GitPrSummary::main),\n            Command.name(\"cc\")\n                    .helptext(\"add one or more labels\")\n                    .main(GitPrCC::main),\n            Command.name(\"csr\")\n                    .helptext(\"require CSR for the pull request\")\n                    .main(GitPrCSR::main),\n            Command.name(\"contributor\")\n                    .helptext(\"add or remove contributors\")\n                    .main(GitPrContributor::main)\n    );\n\n    public static String getHelpForCommand(String command) {\n        Optional<Command> foundCommand = commands.stream().filter(c -> c.name().equals(command)).findFirst();\n\n        if (foundCommand.isPresent()) {\n            return foundCommand.get().helpText();\n        } else {\n            return \"\";\n        }\n    }\n\n    public static void main(String[] args) throws Exception {\n        HttpProxy.setup();\n\n        var parser = new MultiCommandParser(\"git pr\", commands, true);\n        var command = parser.parse(args);\n        command.execute();\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitProxy.java",
    "content": "/*\n * Copyright (c) 2019, 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.proxy.HttpProxy;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.nio.charset.StandardCharsets;\nimport java.util.*;\n\npublic class GitProxy {\n    public static void main(String[] args) throws IOException, InterruptedException {\n        String proxyArgument = null;\n        for (var i = 0; i < args.length; i++) {\n            var arg = args[i];\n            if (arg.equals(\"-c\") && i < (args.length - 1)) {\n                var next = args[i + 1];\n                if (next.startsWith(\"http.proxy\")) {\n                    var parts = next.split(\"=\");\n                    if (parts.length == 2) {\n                        proxyArgument = parts[1];\n                        break;\n                    }\n                }\n            }\n        }\n\n        HttpProxy.setup(proxyArgument);\n\n        var httpsProxyHost = System.getProperty(\"https.proxyHost\");\n        var httpProxyHost = System.getProperty(\"http.proxyHost\");\n\n        if (httpsProxyHost == null && httpProxyHost == null) {\n            System.err.println(\"error: no proxy host specified\");\n            System.err.println(\"\");\n            System.err.println(\"Either set the git config variable 'http.proxy' or\");\n            System.err.println(\"set the environment variables HTTP_PROXY and/or HTTPS_PROXY\");\n            System.exit(1);\n        }\n\n        var httpsProxyPort = System.getProperty(\"https.proxyPort\");\n        var httpProxyPort = System.getProperty(\"http.proxyPort\");\n        var proxyHost = httpsProxyHost != null ? httpsProxyHost : httpProxyHost;\n        var proxyPort = httpsProxyPort != null ? httpsProxyPort : httpProxyPort;\n        var proxy = proxyHost + \":\" + proxyPort;\n\n        System.out.println(\"info: using proxy \" + proxy);\n\n        var gitArgs = new ArrayList<String>();\n        gitArgs.add(\"git\");\n        gitArgs.addAll(Arrays.asList(args));\n        var pb = new ProcessBuilder(gitArgs);\n        var env = pb.environment();\n\n        if (httpProxyHost != null) {\n            env.put(\"HTTP_PROXY\", proxy);\n            env.put(\"http_proxy\", proxy);\n        }\n        if (httpsProxyHost != null) {\n            env.put(\"HTTPS_PROXY\", proxy);\n            env.put(\"https_proxy\", proxy);\n        }\n\n        var os = System.getProperty(\"os.name\").toLowerCase();\n        if (os.startsWith(\"win\")) {\n            for (var dir : System.getenv(\"PATH\").split(\";\")) {\n                if (dir.endsWith(\"Git\\\\cmd\") || dir.endsWith(\"Git\\\\bin\")) {\n                    var gitDir = Path.of(dir).getParent();\n                    var connect = gitDir.resolve(\"mingw64\").resolve(\"bin\").resolve(\"connect.exe\");\n                    if (Files.exists(connect)) {\n                        env.put(\"GIT_SSH_COMMAND\", \"ssh -o ProxyCommand='\" + connect.toAbsolutePath() +\n                                                   \" -H \" + proxy + \" %h %p'\");\n                        break;\n                    }\n                }\n            }\n        } else if (os.startsWith(\"mac\")) {\n            // Need to use the BSD netcat since homebrew might install GNU netcat\n            env.put(\"GIT_SSH_COMMAND\", \"ssh -o ProxyCommand='/usr/bin/nc -X connect -x \" + proxy + \" %h %p'\");\n        } else {\n            // Assume GNU/Linux and GNU netcat\n            env.put(\"GIT_SSH_COMMAND\", \"ssh -o ProxyCommand='nc --proxy \" + proxy + \" %h %p'\");\n        }\n        pb.inheritIO();\n        System.exit(pb.start().waitFor());\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitPublish.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.*;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.*;\nimport java.net.URI;\nimport java.util.*;\nimport java.util.stream.Collectors;\nimport java.util.function.Supplier;\nimport java.util.logging.Level;\n\npublic class GitPublish {\n    private static <T> Supplier<T> die(String fmt, Object... args) {\n        return () -> {\n            System.err.println(String.format(fmt, args));\n            System.exit(1);\n            return null;\n        };\n    }\n\n    private static class RecordingOutputStream extends OutputStream {\n        private final OutputStream target;\n        private final boolean shouldForward;\n        private byte[] output;\n        private int index;\n\n        RecordingOutputStream(OutputStream target, boolean shouldForward) {\n            this.target = target;\n            this.shouldForward = shouldForward;\n            this.output = new byte[1024];\n            this.index = 0;\n        }\n\n        @Override\n        public void write(int b) throws IOException {\n            if (index == output.length) {\n                output = Arrays.copyOf(output, output.length * 2);\n            }\n            output[index] = (byte) b;\n            index++;\n\n            if (shouldForward) {\n                target.write(b);\n                target.flush();\n            }\n        }\n\n        String output() {\n            return new String(output, 0, index + 1, StandardCharsets.UTF_8);\n        }\n    }\n\n    private static int pushAndFollow(String remote, Branch b, boolean isQuiet, String browser) throws IOException, InterruptedException {\n        var pb = new ProcessBuilder(\"git\", \"push\", \"--set-upstream\", \"--progress\", remote, b.name());\n        if (isQuiet) {\n            pb.redirectOutput(ProcessBuilder.Redirect.DISCARD);\n        } else {\n            pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);\n        }\n        pb.redirectError(ProcessBuilder.Redirect.PIPE);\n        var p = pb.start();\n        var recording = new RecordingOutputStream(System.err, !isQuiet);\n        p.getErrorStream().transferTo(recording);\n        int err = p.waitFor();\n        if (err == 0) {\n            var lines = recording.output().lines().collect(Collectors.toList());\n            for (var line : lines) {\n                if (line.startsWith(\"remote:\")) {\n                    var parts = line.split(\"\\\\s\");\n                    for (var part : parts) {\n                        if (part.startsWith(\"https://\")) {\n                            var browserPB = new ProcessBuilder(browser, part);\n                            browserPB.start().waitFor(); // don't care about status\n                            break;\n                        }\n                    }\n                }\n            }\n        }\n        return err;\n    }\n\n    private static int push(String remote, Branch b, boolean isQuiet) throws IOException, InterruptedException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"push\"));\n        if (isQuiet) {\n            cmd.add(\"--quiet\");\n        }\n        cmd.addAll(List.of(\"--set-upstream\", remote, b.name()));\n        var pb = new ProcessBuilder(cmd);\n        pb.inheritIO();\n        return pb.start().waitFor();\n    }\n\n    private static String getOption(String name, Arguments arguments, ReadOnlyRepository repo) throws IOException {\n        var arg = ForgeUtils.getOption(name, arguments);\n        if (arg != null) {\n            return arg;\n        }\n\n        var lines = repo.config(\"publish.\" + name);\n        return lines.size() == 1 ? lines.get(0) : null;\n    }\n\n    private static boolean getSwitch(String name, Arguments arguments, ReadOnlyRepository repo) throws IOException {\n        if (arguments.contains(name)) {\n            return true;\n        }\n\n        var lines = repo.config(\"publish.\" + name);\n        return lines.size() == 1 && lines.get(0).toLowerCase().equals(\"true\");\n    }\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var flags = List.of(\n            Switch.shortcut(\"q\")\n                  .fullname(\"quiet\")\n                  .helptext(\"Silence all output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"browse\")\n                  .helptext(\"Open link returned by remote in web browser\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"verbose\")\n                  .helptext(\"Turn on verbose output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"debug\")\n                  .helptext(\"Turn on debugging output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"version\")\n                  .helptext(\"Print the version of this tool\")\n                  .optional());\n\n        var inputs = List.of(\n            Input.position(0)\n                 .describe(\"ORIGIN\")\n                 .singular()\n                 .optional()\n        );\n\n        var parser = new ArgumentParser(\"git-publish\", flags, inputs);\n        var arguments = parser.parse(args);\n\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-publish version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            Logging.setup(level);\n        }\n\n        var cwd = Path.of(\"\").toAbsolutePath();\n        var repo = Repository.get(cwd).or(die(\"error: no repository found at \" + cwd.toString())).get();\n        var remote = arguments.at(0).orString(\"origin\");\n\n        var pushPath = repo.pushPath(remote);\n        if (pushPath.startsWith(\"http://\") || pushPath.startsWith(\"https://\")) {\n            var uri = URI.create(pushPath);\n            var token = System.getenv(\"GIT_TOKEN\");\n            var username = getOption(\"username\", arguments, repo);\n            var credentials = GitCredentials.fill(uri.getHost(),\n                                                  uri.getPath(),\n                                                  username,\n                                                  token,\n                                                  uri.getScheme());\n            if (credentials.password() == null) {\n                die(\"error: no personal access token found, use git-credentials or the environment variable GIT_TOKEN\");\n            }\n            if (credentials.username() == null) {\n                die(\"error: no username for \" + uri.getHost() + \" found, use git-credentials or the flag --username\");\n            }\n            if (token != null) {\n                GitCredentials.approve(credentials);\n            }\n        }\n\n        var currentBranch = repo.currentBranch();\n        if (currentBranch.isEmpty()) {\n            System.err.println(\"error: the repository is in a detached HEAD state\");\n            System.exit(1);\n        }\n\n        var branch = repo.currentBranch().get();\n        var isQuiet = getSwitch(\"quiet\", arguments, repo);\n        var shouldBrowse = getSwitch(\"browse\", arguments, repo);\n        int err = 0;\n        if (shouldBrowse) {\n            var browser = getOption(\"browser\", arguments, repo);\n            if (browser == null) {\n                var os = System.getProperty(\"os.name\").toLowerCase();\n                if (os.startsWith(\"win\")) {\n                    browser = \"explorer\";\n                } else if (os.startsWith(\"mac\")) {\n                    browser = \"open\";\n                } else {\n                    // Assume GNU/Linux\n                    browser = \"xdg-open\";\n                }\n            }\n            err = pushAndFollow(remote, branch, isQuiet, browser);\n        } else {\n            err = push(remote, branch, isQuiet);\n        }\n\n        if (err != 0) {\n            System.exit(err);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitSkara.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.Main;\nimport org.openjdk.skara.network.UncheckedRestException;\nimport org.openjdk.skara.vcs.Repository;\nimport org.openjdk.skara.vcs.git.GitVersion;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\npublic class GitSkara {\n    private static final Map<String, Main> commands = new TreeMap<>();\n    private static final Set<String> mercurialCommands = Set.of(\"webrev\", \"defpath\", \"jcheck\");\n\n    private static void help(String[] args) throws Exception {\n        var isMercurial = args.length > 0 && args[0].equals(\"--mercurial\");\n        if (args.length > 0 && !isMercurial) {\n            var command = args[0];\n            if (commands.containsKey(command)) {\n                commands.get(command).main(new String[]{\"-h\"});\n            } else {\n                System.err.println(\"error: unknown command: \" + command);\n            }\n        }\n\n        var skaraCommands = Set.of(\"help\", \"version\", \"update\");\n\n        var names = new ArrayList<String>();\n        if (isMercurial) {\n            names.addAll(mercurialCommands);\n            names.addAll(skaraCommands);\n        } else {\n            names.addAll(commands.keySet().stream()\n                                          .filter(s -> !s.startsWith(\"-\"))\n                                          .filter(s -> !s.equals(\"debug\"))\n                                          .collect(Collectors.toList()));\n        }\n\n        var vcs = isMercurial ? \"hg\" : \"git\";\n        System.out.println(\"usage: \" + vcs + \" skara <\" + String.join(\"|\", names) + \">\");\n        System.out.println(\"\");\n        System.out.println(\"Additional available \" + vcs + \" commands:\");\n        for (var name : names) {\n            if (!skaraCommands.contains(name)) {\n                if (isMercurial) {\n                    if (mercurialCommands.contains(name)) {\n                        System.out.println(\"- hg \" + name);\n                    }\n                } else {\n                    System.out.println(\"- git \" + name);\n                }\n            }\n        }\n        System.out.println(\"\");\n        System.out.println(\"To learn more about a particular command, run:\");\n        System.out.println(\"\");\n        System.out.println(\"    \" + vcs + \" <command> -h\");\n        System.out.println(\"\");\n        System.out.println(\"For more information, please see the Skara wiki:\");\n        System.out.println(\"\");\n        if (isMercurial) {\n            System.out.println(\"    https://wiki.openjdk.org/display/SKARA/Mercurial\");\n        } else {\n            System.out.println(\"    https://wiki.openjdk.org/display/skara\");\n        }\n        System.out.println(\"\");\n        System.exit(0);\n    }\n\n    private static void version(String[] args) {\n        var isMercurial = args.length > 0 && args[0].equals(\"--mercurial\");\n        var vcs = isMercurial ? \"hg\" : \"git\";\n        System.out.println(vcs + \" skara version: \" + Version.fromManifest().orElse(\"unknown\"));\n        System.exit(0);\n    }\n\n    private static List<String> config(String key, boolean isMercurial) throws IOException, InterruptedException {\n        var vcs = isMercurial ? \"hg\" : \"git\";\n        var pb = new ProcessBuilder(vcs, \"config\", key);\n        pb.redirectOutput(ProcessBuilder.Redirect.PIPE);\n        pb.redirectError(ProcessBuilder.Redirect.INHERIT);\n        var p = pb.start();\n        var value = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);\n        p.waitFor();\n        return Arrays.asList(value.split(\"\\n\"));\n    }\n\n    private static void update(String[] args) throws IOException, InterruptedException {\n        var isMercurial = args.length > 0 && args[0].equals(\"--mercurial\");\n\n        String line = null;\n        if (isMercurial) {\n            var lines = config(\"extensions.skara\", true);\n            if (lines.size() == 1) {\n                line = lines.get(0);\n            } else {\n                System.err.println(\"error: could not find skara repository\");\n                System.exit(1);\n            }\n        } else {\n            var lines = config(\"include.path\", false);\n            var entry = lines.stream().filter(l -> l.endsWith(\"skara.gitconfig\")).findFirst();\n            if (entry.isEmpty()) {\n                System.err.println(\"error: could not find skara repository\");\n                System.exit(1);\n            }\n            line = entry.get();\n        }\n\n        var expanded = line.startsWith(\"~\") ?\n            System.getProperty(\"user.home\") + line.substring(1) : line;\n        var path = Path.of(expanded);\n        if (!Files.exists(path)) {\n            System.err.println(\"error: \" + path + \" does not exist\");\n            System.exit(1);\n        }\n        var parent = path.getParent();\n        var repo = Repository.get(parent);\n        if (repo.isEmpty()) {\n            System.err.println(\"error: could not find skara repository\");\n            System.exit(1);\n        }\n\n        var head = repo.get().head();\n        System.out.println(\"Checking for updates ...\");\n        repo.get().pull();\n        for (var s : repo.get().submodules()) {\n            repo.get().updateSubmodule(s);\n        }\n        var newHead = repo.get().head();\n\n        if (!head.equals(newHead)) {\n            System.out.println(\"Found the following updates:\");\n            var commits = repo.get().commitMetadata(head, newHead);\n            for (var commit : commits) {\n                var message = CommitMessageParsers.v1.parse(commit);\n                System.out.println(\"- \" + message.title());\n            }\n            System.out.println(\"Rebuilding ...\");\n            var cmd = new ArrayList<String>();\n            if (System.getProperty(\"os.name\").toLowerCase().startsWith(\"win\")) {\n                cmd.add(parent.resolve(\"gradlew.bat\").toString());\n            } else {\n                cmd.addAll(List.of(\"sh\", \"gradlew\"));\n            }\n\n            var pb = new ProcessBuilder(cmd);\n            pb.inheritIO();\n            pb.directory(parent.toFile());\n            var p = pb.start();\n            var res = p.waitFor();\n            if (res != 0) {\n                System.err.println(\"error: could not build Skara tooling\");\n                System.exit(1);\n            }\n        } else {\n            System.out.println(\"No updates found\");\n        }\n    }\n\n    private static void checkGitVersion() {\n        try {\n            GitVersion version = GitVersion.get();\n            if (!version.isKnownSupported()) {\n                System.err.println(\"WARNING: Your git version is: \" + version + \",\" +\n                        \" which is not a known supported version.\" +\n                        \" Please consider upgrading to a more recent version.\");\n            }\n        } catch (IOException e) {\n            System.err.println(\"Could not check git version: \" + e.getMessage());\n        }\n    }\n\n    public static void main(String[] args) throws Exception {\n        commands.put(\"jcheck\", GitJCheck::main);\n        commands.put(\"webrev\", GitWebrev::main);\n        commands.put(\"defpath\", GitDefpath::main);\n        commands.put(\"debug\", SkaraDebug::main);\n        commands.put(\"fork\", GitFork::main);\n        commands.put(\"pr\", GitPr::main);\n        commands.put(\"token\", GitToken::main);\n        commands.put(\"info\", GitInfo::main);\n        commands.put(\"translate\", GitTranslate::main);\n        commands.put(\"sync\", GitSync::main);\n        commands.put(\"publish\", GitPublish::main);\n        commands.put(\"proxy\", GitProxy::main);\n        commands.put(\"trees\", GitTrees::main);\n        commands.put(\"hg-export\", GitHgExport::main);\n        commands.put(\"backport\", GitBackport::main);\n\n        commands.put(\"update\", GitSkara::update);\n        commands.put(\"-h\", GitSkara::help);\n        commands.put(\"--help\", GitSkara::help);\n        commands.put(\"help\", GitSkara::help);\n        commands.put(\"version\", GitSkara::version);\n        commands.put(\"--version\", GitSkara::version);\n        commands.put(\"-v\", GitSkara::version);\n\n        checkGitVersion();\n\n        var isEmpty = args.length == 0;\n        var command = isEmpty ? \"help\" : args[0];\n        var commandArgs = isEmpty ? new String[0] : Arrays.copyOfRange(args, 1, args.length);\n        if (commands.containsKey(command)) {\n            try {\n                commands.get(command).main(commandArgs);\n            } catch (UncheckedRestException e) {\n                if (e.getStatusCode() == 401) {\n                    System.err.println(\"Unauthorized: You do not have access to \" + e.getRequest().uri().toString());\n                    System.err.println(\"Please see the page below to correctly configure your personal access token.\");\n                    System.err.println(\"https://wiki.openjdk.org/display/SKARA/CLI+Tools#CLITools-PersonalAccessToken\");\n                } else {\n                    throw e;\n                }\n            }\n        } else {\n            System.err.println(\"error: unknown command: \" + command);\n            help(new String[0]);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitSync.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.proxy.HttpProxy;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.*;\nimport java.net.*;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.logging.*;\n\npublic class GitSync {\n    private final Repository repo;\n    private final Arguments arguments;\n    private final List<String> remotes;\n    private final boolean isDryRun;\n    private String targetName;\n    private URI targetURI;\n    private String sourceName;\n    private URI sourceURI;\n\n    private GitSync(Repository repo, Arguments arguments) throws IOException {\n        this.repo = repo;\n        this.arguments = arguments;\n        this.remotes = repo.remotes();\n        this.isDryRun = arguments.contains(\"dry-run\");\n    }\n\n    private void logVerbose(String message) {\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            System.out.println(message);\n        }\n    }\n\n    private URI getRemoteURI(String name) throws IOException {\n        if (name != null) {\n            if (remotes.contains(name)) {\n                return Remote.toURI(repo.pullPath(name));\n            } else {\n                try {\n                    return Remote.toURI(name);\n                } catch (IOException e) {\n                    die(name + \" is not a known git remote, nor a proper git URI\");\n                }\n            }\n        }\n        return null;\n    }\n\n    private String getOption(String name) throws IOException {\n        var arg = ForgeUtils.getOption(name, arguments);\n        if (arg != null) {\n            return arg;\n        }\n        var lines = repo.config(\"sync.\" + name);\n        return lines.size() == 1 ? lines.get(0) : null;\n    }\n\n    private void syncBranch(String name) throws IOException {\n        Hash fetchHead = null;\n        logVerbose(\"Fetching branch \" + name + \" from  \" + sourceURI);\n        if (!isDryRun) {\n            fetchHead = repo.fetch(sourceURI, name).orElseThrow();\n        }\n        logVerbose(\"Pushing to \" + targetURI);\n        if (!isDryRun) {\n            repo.push(fetchHead, targetURI, name);\n        }\n    }\n\n    private void fetchTarget() throws IOException {\n        if (isDryRun) return;\n\n        repo.fetchRemote(targetName);\n    }\n\n    private void pull() throws IOException, InterruptedException {\n        if (isDryRun) return;\n\n        var pb = new ProcessBuilder(\"git\", \"pull\");\n        pb.directory(repo.root().toFile());\n        pb.inheritIO();\n        var result = pb.start().waitFor();\n        if (result != 0) {\n            die(\"Failure running git pull, exit code \" + result);\n        }\n    }\n\n    private void mergeFastForward(String ref) throws IOException, InterruptedException {\n        if (isDryRun) return;\n\n        var pb = new ProcessBuilder(\"git\", \"merge\", \"--ff-only\", \"--quiet\", ref);\n        pb.directory(repo.root().toFile());\n        pb.inheritIO();\n        var result = pb.start().waitFor();\n\n        if (result != 0) {\n            die(\"Failure running git merge, exit code \" + result);\n        }\n    }\n\n    private void moveBranch(Branch branch, Hash to) throws IOException, InterruptedException {\n        if (isDryRun) return;\n\n        var pb = new ProcessBuilder(\"git\", \"branch\", \"--force\", branch.name(), to.hex());\n        pb.directory(repo.root().toFile());\n        pb.inheritIO();\n        var result = pb.start().waitFor();\n\n        if (result != 0) {\n            die(\"Failure running git branch, exit code \" + result);\n        }\n    }\n\n    private void setupTargetAndSource() throws IOException {\n        String targetFromOptions = getOption(\"to\");\n        URI targetFromOptionsURI = getRemoteURI(targetFromOptions);\n\n        String sourceFromOptions = getOption(\"from\");\n        URI sourceFromOptionsURI = getRemoteURI(sourceFromOptions);\n\n        // Find push target repo\n        if (!remotes.contains(\"origin\")) {\n            if (targetFromOptions != null) {\n                // If 'origin' is missing but we have command line arguments, use these instead\n                targetName = targetFromOptions;\n                targetURI = targetFromOptionsURI;\n            } else {\n                die(\"repo does not have an 'origin' remote defined\");\n            }\n        } else {\n            targetName = \"origin\";\n            targetURI = Remote.toURI(repo.pullPath(targetName));\n            if (targetFromOptions != null) {\n                if (!equalsCanonicalized(targetFromOptionsURI, targetURI)) {\n                    logVerbose(\"Overriding target 'origin' with \" + targetFromOptions);\n                    targetName = targetFromOptions;\n                    targetURI = targetFromOptionsURI;\n                }\n            }\n        }\n\n        // Find pull source as given by command line options\n        if (sourceFromOptions != null) {\n            if (!sameHost(sourceFromOptionsURI, targetURI)) {\n                if (!arguments.contains(\"force\")) {\n                    System.err.println(\"error: The from and to remote repositories are hosted on different forges\");\n                    System.err.println(\"       The from remote is \" + sourceFromOptionsURI);\n                    System.err.println(\"       The to remote is \" + targetURI);\n                    System.err.println(\"       Rerun with --force if this was intended\");\n                    System.exit(1);\n                }\n            }\n            logVerbose(\"Replacing source repo with \" + sourceFromOptionsURI + \" from command line options\");\n            sourceName = sourceFromOptions;\n            sourceURI = sourceFromOptionsURI;\n        } else {\n            // This may return null, if so, we fall back on just comparing hostnames further down\n            var remoteForkParentURI = findRemoteForkParent();\n\n            if (remotes.contains(\"upstream\")) {\n                // Find pull source as given by Git's 'upstream' remote\n                var sourceUpstreamURI = Remote.toURI(repo.pullPath(\"upstream\"));\n                if (remoteForkParentURI != null) {\n                    if (!equalsCanonicalized(sourceUpstreamURI, remoteForkParentURI)) {\n                        System.err.println(\"error: git 'upstream' remote and the parent fork given by the Git Forge differ\");\n                        System.err.println(\"       Git 'upstream' remote is \" + sourceUpstreamURI);\n                        System.err.println(\"       Git Forge parent is \" + remoteForkParentURI);\n                        System.err.println(\"       Remove incorrect 'upstream' remote with 'git remote remove upstream'\");\n                        System.err.println(\"       or run with --force to use 'upstream' remote anyway\");\n                        System.exit(1);\n                    }\n                } else {\n                    if (!sameHost(sourceUpstreamURI, targetURI)) {\n                        if (!arguments.contains(\"force\")) {\n                            System.err.println(\"error: The from and to remote repositories are hosted on different forges\");\n                            System.err.println(\"       The from remote is \" + sourceUpstreamURI);\n                            System.err.println(\"       The to remote is \" + targetURI);\n                            System.err.println(\"       Rerun with --force if this was intended\");\n                            System.exit(1);\n                        }\n                    }\n                }\n                sourceName = \"upstream\";\n                sourceURI = sourceUpstreamURI;\n            } else if (remoteForkParentURI != null) {\n                // Repo is badly configured, fix it unless instructed not to\n                if (!arguments.contains(\"no-remote\")) {\n                    System.out.println(\"Setting 'upstream' remote to \" + remoteForkParentURI);\n                    if (!isDryRun) {\n                        repo.addRemote(\"upstream\", remoteForkParentURI.toString());\n                    }\n                }\n                sourceName = \"upstream\";\n                sourceURI = remoteForkParentURI;\n            }\n        }\n\n        if (sourceURI == null) {\n            System.err.println(\"error: could not find repository to sync from, please specify one with --from\");\n            System.err.println(\"       or add a remote named 'upstream'\");\n            System.exit(1);\n        }\n\n        if (equalsCanonicalized(targetURI, sourceURI)) {\n            System.err.println(\"error: --from and --to refer to the same repository: \" + targetURI);\n            System.exit(1);\n        }\n        setupSourceCredentials();\n    }\n\n    private URI findRemoteForkParent() throws IOException {\n        var targetScheme = targetURI.getScheme();\n        if (!arguments.contains(\"force\") && targetScheme.equals(\"https\") || targetScheme.equals(\"http\")) {\n            var credentials = setupTargetCredentials();\n\n            // Find pull source as given by the Git Forge as the repository's parent\n            var forgeWebURI = Remote.toWebURI(targetURI.toString());\n            try {\n                var sourceParentURI = ForgeUtils.from(forgeWebURI, credentials)\n                        .flatMap(f -> f.repository(forgeWebURI.getPath().substring(1)))\n                        .flatMap(HostedRepository::parent)\n                        .map(HostedRepository::webUrl);\n\n                if (sourceParentURI.isPresent()) {\n                    logVerbose(\"Git Forge reports upstream parent is \" + sourceParentURI.get());\n                    return sourceParentURI.get();\n                }\n            } catch (UncheckedIOException e) {\n                System.err.println(\"Failed to contact target forge: \" + targetURI);\n                var message = e.getCause().getMessage();\n                if (message != null) {\n                    System.err.println(message);\n                }\n                System.err.println(\"Skipping remote fork parent check\");\n            }\n        }\n        return null;\n    }\n\n    private void setupSourceCredentials() throws IOException {\n        var sourceScheme = sourceURI.getScheme();\n        if (sourceScheme.equals(\"https\") || sourceScheme.equals(\"http\")) {\n            var token = System.getenv(\"GIT_TOKEN\");\n            var username = getOption(\"username\");\n            var credentials = GitCredentials.fill(sourceURI.getHost(),\n                    sourceURI.getPath(),\n                    username,\n                    token,\n                    sourceScheme);\n            if (credentials.password() != null && credentials.username() != null && token != null) {\n                sourceURI = URI.create(sourceScheme + \"://\" + credentials.username() + \":\" + credentials.password() + \"@\" + sourceURI.getHost() + sourceURI.getPath());\n            }\n        }\n    }\n\n    private Credential setupTargetCredentials() throws IOException {\n        var targetScheme = targetURI.getScheme();\n        if (targetScheme.equals(\"https\") || targetScheme.equals(\"http\")) {\n            var token = System.getenv(\"GIT_TOKEN\");\n            var username = getOption(\"username\");\n            var credentials = GitCredentials.fill(targetURI.getHost(),\n                    targetURI.getPath(),\n                    username,\n                    token,\n                    targetScheme);\n            if (credentials.password() == null) {\n                die(\"no personal access token found, use git-credentials or the environment variable GIT_TOKEN\");\n            }\n            if (credentials.username() == null) {\n                die(\"no username for \" + targetURI.getHost() + \" found, use git-credentials or the flag --username\");\n            }\n            if (token != null) {\n                targetURI = URI.create(targetScheme + \"://\" + credentials.username() + \":\" + credentials.password() + \"@\" +\n                        targetURI.getHost() + targetURI.getPath());\n            } else {\n                GitCredentials.approve(credentials);\n            }\n            return new Credential(credentials.username(), credentials.password());\n        }\n        return null;\n    }\n\n    public void sync() throws IOException, InterruptedException {\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-sync version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            Logging.setup(level);\n        }\n\n        if (isDryRun) {\n            System.out.println(\"Running in dry-run mode. No actual changes will be performed\");\n        }\n\n        HttpProxy.setup();\n\n        // Setup source (from, upstream) and target (to, origin) repo names and URIs\n        setupTargetAndSource();\n        System.out.println(\"Will sync changes from \" + sourceURI + \" to \" + targetURI);\n\n        var branches = new HashSet<String>();\n        var branchesArg = getOption(\"branches\");\n        if (branchesArg != null) {\n            var requested = branchesArg.split(\",\");\n            for (var branch : requested) {\n                branches.add(branch.trim());\n            }\n        }\n\n        var ignore = Pattern.compile(\"pr/.*\");\n        var ignoreArg = getOption(\"ignore\");\n        if (ignoreArg != null) {\n            ignore = Pattern.compile(ignoreArg);\n        }\n\n        var remoteBranches = repo.remoteBranches(sourceName);\n        for (var branch : remoteBranches) {\n            var name = branch.name();\n            if (!branches.isEmpty() && !branches.contains(name)) {\n                logVerbose(\"Skipping branch \" + name);\n                continue;\n            }\n            if (!branches.contains(name) && ignore.matcher(name).matches()) {\n                logVerbose(\"Skipping branch \" + name);\n                continue;\n            }\n\n            System.out.println(\"Syncing \" + sourceName + \"/\" + name + \" to \" + targetName + \"/\" + name + \"... \");\n            syncBranch(name);\n            System.out.println(\"Done syncing\");\n        }\n\n        var shouldPull = arguments.contains(\"pull\");\n        if (!shouldPull) {\n            var lines = repo.config(\"sync.pull\");\n            shouldPull = lines.size() == 1 && lines.get(0).equalsIgnoreCase(\"true\");\n        }\n        if (shouldPull) {\n            var currentBranch = repo.currentBranch();\n            if (currentBranch.isPresent()) {\n                var upstreamBranch = repo.upstreamFor(currentBranch.get());\n                if (upstreamBranch.isPresent()) {\n                    logVerbose(\"Pulling from \" + repo);\n                    pull();\n                }\n            }\n        }\n\n        var shouldFastForward = arguments.contains(\"fast-forward\");\n        if (!shouldFastForward) {\n            var lines = repo.config(\"sync.fast-forward\");\n            shouldFastForward = lines.size() == 1 && lines.get(0).equalsIgnoreCase(\"true\");\n        }\n        if (shouldFastForward) {\n            if (!remotes.contains(targetName)) {\n                die(\"--fast-forward can only be used when --to is the name of a remote\");\n            }\n            logVerbose(\"Fetching from remote \" + targetName);\n            fetchTarget();\n\n            var remoteBranchNames = new HashSet<String>();\n            for (var branch : remoteBranches) {\n                remoteBranchNames.add(targetName + \"/\" + branch.name());\n            }\n\n            var currentBranch = repo.currentBranch();\n            var localBranches = repo.branches();\n            for (var branch : localBranches) {\n                var upstreamBranch = repo.upstreamFor(branch);\n                if (upstreamBranch.isPresent() && remoteBranchNames.contains(upstreamBranch.get())) {\n                    var localHash = repo.resolve(branch);\n                    var upstreamHash = repo.resolve(upstreamBranch.get());\n                    if (localHash.isPresent() && upstreamHash.isPresent() &&\n                        !upstreamHash.equals(localHash) &&\n                        repo.isAncestor(localHash.get(), upstreamHash.get())) {\n                        if (currentBranch.isPresent() && branch.equals(currentBranch.get())) {\n                            logVerbose(\"Fast-forwarding current branch\");\n                            mergeFastForward(upstreamBranch.get());\n                        } else {\n                            logVerbose(\"Fast-forwarding branch \" + upstreamBranch.get());\n                            moveBranch(branch, upstreamHash.get());\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    private static IOException die(String message) {\n        System.err.println(\"error: \" + message);\n        System.exit(1);\n        return new IOException(\"will never reach here\");\n    }\n\n    private static boolean equalsCanonicalized(URI a, URI b) throws IOException {\n        if (a == null || b == null) {\n            if (a == null && b == null) {\n                return true;\n            }\n            return false;\n        }\n\n        var canonicalA = Remote.toWebURI(Remote.canonicalize(a).toString());\n        var canonicalB = Remote.toWebURI(Remote.canonicalize(b).toString());\n        return canonicalA.equals(canonicalB);\n    }\n\n    private static boolean sameHost(URI sourceUpstreamURI, URI targetURI) {\n        return sourceUpstreamURI.getHost().equals(targetURI.getHost());\n    }\n\n\n    private static Arguments parseArguments(String[] args) {\n        var flags = List.of(\n            Option.shortcut(\"\")\n                  .fullname(\"from\")\n                  .describe(\"REMOTE\")\n                  .helptext(\"Fetch changes from this remote\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"to\")\n                  .describe(\"REMOTE\")\n                  .helptext(\"Push changes to this remote\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"branches\")\n                  .describe(\"BRANCHES\")\n                  .helptext(\"Comma separated list of branches to sync\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"ignore\")\n                  .describe(\"PATTERN\")\n                  .helptext(\"Regular expression of branches to ignore\")\n                  .optional(),\n            Option.shortcut(\"u\")\n                  .fullname(\"username\")\n                  .describe(\"NAME\")\n                  .helptext(\"Username on forge\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"pull\")\n                  .helptext(\"Pull current branch from origin after successful sync\")\n                  .optional(),\n            Switch.shortcut(\"ff\")\n                  .fullname(\"fast-forward\")\n                  .helptext(\"Fast forward all local branches where possible\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                   .fullname(\"no-remote\")\n                   .helptext(\"Do not add an additional git remote\")\n                   .optional(),\n            Switch.shortcut(\"n\")\n                   .fullname(\"dry-run\")\n                   .helptext(\"Only simulate behavior, do no actual changes\")\n                   .optional(),\n            Switch.shortcut(\"\")\n                   .fullname(\"force\")\n                   .helptext(\"Force syncing even between unrelated repos (beware!)\")\n                   .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"verbose\")\n                  .helptext(\"Turn on verbose output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"debug\")\n                  .helptext(\"Turn on debugging output\")\n                  .optional(),\n            Switch.shortcut(\"v\")\n                  .fullname(\"version\")\n                  .helptext(\"Print the version of this tool\")\n                  .optional()\n        );\n\n        var parser = new ArgumentParser(\"git sync\", flags);\n        return parser.parse(args);\n    }\n\n    public static void sync(Repository repo, String[] args) throws IOException, InterruptedException {\n        GitSync commandExecutor = new GitSync(repo, parseArguments(args));\n        commandExecutor.sync();\n    }\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var cwd = Paths.get(\"\").toAbsolutePath();\n        var repo = Repository.get(cwd).orElseThrow(() ->\n                die(\"no repository found at \" + cwd)\n        );\n\n        GitSync commandExecutor = new GitSync(repo, parseArguments(args));\n        commandExecutor.sync();\n    }\n}\n\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitToken.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.util.List;\nimport java.util.logging.Level;\n\npublic class GitToken {\n    private static void exit(String fmt, Object...args) {\n        System.err.println(String.format(fmt, args));\n        System.exit(1);\n    }\n\n    public static void main(String[] args) throws IOException {\n        var flags = List.of(\n            Switch.shortcut(\"\")\n                  .fullname(\"verbose\")\n                  .helptext(\"Turn on verbose output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"debug\")\n                  .helptext(\"Turn on debugging output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"version\")\n                  .helptext(\"Print the version of this tool\")\n                  .optional());\n\n        var inputs = List.of(\n            Input.position(0)\n                 .describe(\"store|revoke\")\n                 .singular()\n                 .required(),\n            Input.position(1)\n                 .describe(\"URI\")\n                 .singular()\n                 .required()\n        );\n\n        var parser = new ArgumentParser(\"git-token\", flags, inputs);\n        var arguments = parser.parse(args);\n\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-token version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            Logging.setup(level);\n        }\n\n        var command = arguments.at(0).asString();\n        var uri = arguments.at(1).via(URI::create);\n        if (uri.getScheme() == null) {\n            uri = URI.create(\"https://\" + uri.toString());\n        }\n\n        if (command.equals(\"store\")) {\n            System.out.println(\"info: if you are prompted for a password, fill in your personal access token,\\n\" +\n                               \"      *not* your login password for \" + uri.toString());\n            var credentials = GitCredentials.fill(uri.getHost(), uri.getPath(), null, null, uri.getScheme());\n            GitCredentials.approve(credentials);\n        } else if (command.equals(\"revoke\")) {\n            var credentials = GitCredentials.fill(uri.getHost(), uri.getPath(), null, null, uri.getScheme());\n            GitCredentials.reject(credentials);\n        } else {\n            exit(\"error: unknown command: \" + command);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitTranslate.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.vcs.ReadOnlyRepository;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.IOException;\nimport java.util.*;\nimport java.util.logging.*;\nimport java.nio.file.*;\n\npublic class GitTranslate {\n    private static void exit(String fmt, Object...args) {\n        System.err.println(String.format(fmt, args));\n        System.exit(1);\n    }\n\n    public static void main(String[] args) throws IOException {\n        var flags = List.of(\n            Option.shortcut(\"\")\n                  .fullname(\"map\")\n                  .describe(\"FILE\")\n                  .helptext(\"File with commit mapping (defaults to .hgcommits)\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"to-hg\")\n                  .describe(\"REV\")\n                  .helptext(\"Translate from git to hg\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"from-hg\")\n                  .describe(\"REV\")\n                  .helptext(\"Translate from hg to git\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"verbose\")\n                  .helptext(\"Turn on verbose output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"debug\")\n                  .helptext(\"Turn on debugging output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"version\")\n                  .helptext(\"Print the version of this tool\")\n                  .optional()\n        );\n\n        var inputs = List.of(\n            Input.position(0)\n                 .describe(\"REV\")\n                 .singular()\n                 .required()\n        );\n\n        var parser = new ArgumentParser(\"git-translate\", flags, inputs);\n        var arguments = parser.parse(args);\n\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-translate version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            LogManager.getLogManager().reset();\n            var log = Logger.getLogger(\"org.openjdk.skara\");\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            log.setLevel(level);\n\n            ConsoleHandler handler = new ConsoleHandler();\n            handler.setFormatter(new MinimalFormatter());\n            handler.setLevel(level);\n            log.addHandler(handler);\n        }\n\n        var cwd = Path.of(\"\").toAbsolutePath();\n        var repo = ReadOnlyRepository.get(cwd);\n        if (repo.isEmpty()) {\n            exit(\"error: no git repository found at \" + cwd.toString());\n        }\n\n\n        var hgCommits = repo.get().root().resolve(\".hgcommits\");\n        var map = arguments.contains(\"map\") ?\n            arguments.get(\"map\").via(Path::of) : hgCommits;\n\n        if (!Files.exists(map)) {\n            exit(\"error: could not find file with commit info\");\n        }\n\n        var ref = arguments.at(0).asString();\n        if (ref == null) {\n            exit(\"error: no revision given\");\n        }\n\n        var mapping = new HashMap<String, String>();\n        if (arguments.contains(\"to-hg\")) {\n            var rev = repo.get().resolve(ref);\n            if (rev.isEmpty()) {\n                exit(\"error: could not resolve \" + ref);\n            }\n            for (var line : Files.readAllLines(map)) {\n                var parts = line.split(\" \");\n                mapping.put(parts[0], parts[1]);\n            }\n            var hash = rev.get().hex();\n            if (mapping.containsKey(hash)) {\n                System.out.println(mapping.get(hash));\n            } else {\n                exit(\"error: no mapping to hg from git commit \" + hash);\n            }\n        } else if (arguments.contains(\"from-hg\")) {\n            for (var line : Files.readAllLines(map)) {\n                var parts = line.split(\" \");\n                mapping.put(parts[1], parts[0]);\n            }\n            if (mapping.containsKey(ref)) {\n                System.out.println(mapping.get(ref));\n            } else {\n                exit(\"error: no mapping to git from hg commit \" + ref);\n            }\n        } else {\n            exit(\"error: either --to-hg or --from-hg must be set\");\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitTrees.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.proxy.HttpProxy;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.nio.charset.StandardCharsets;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\npublic class GitTrees {\n    private static boolean isRepository(Path root, boolean isMercurial) {\n        var hidden = isMercurial ? root.resolve(\".hg\") : root.resolve(\".git\");\n        return Files.exists(hidden) && Files.isDirectory(hidden);\n    }\n\n    private static Path root(boolean isMercurial) throws IOException, InterruptedException {\n        var pb = isMercurial ?\n            new ProcessBuilder(\"hg\", \"root\") :\n            new ProcessBuilder(\"git\", \"rev-parse\", \"--show-toplevel\");\n        pb.redirectOutput(ProcessBuilder.Redirect.PIPE);\n        pb.redirectError(ProcessBuilder.Redirect.INHERIT);\n        var p = pb.start();\n        var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip();\n        var res = p.waitFor();\n        if (res != 0) {\n            System.exit(res);\n        }\n\n        return Path.of(output);\n    }\n\n    private static List<Path> subrepos(Path root, boolean isMercurial) throws IOException {\n        try (var paths = Files.walk(root)) {\n            return paths.filter(d -> d.getFileName().toString().equals(isMercurial ? \".hg\" : \".git\"))\n                        .map(d -> d.getParent())\n                        .filter(d -> !d.equals(root))\n                        .map(d -> root.relativize(d))\n                        .sorted()\n                        .collect(Collectors.toList());\n        }\n    }\n\n    private static Path treesFile(Path root, boolean isMercurial) {\n        return root.resolve(isMercurial ? \".hg\" : \".git\").resolve(\"trees\");\n    }\n\n    private static List<Path> tconfig(Path root, boolean isMercurial) throws IOException {\n        var subrepos = subrepos(root, isMercurial);\n        var treesFile = treesFile(root, isMercurial);\n        Files.write(treesFile, subrepos.stream().map(Path::toString).collect(Collectors.toList()));\n\n        for (var subrepo : subrepos) {\n            var subSubRepos = new ArrayList<Path>();\n            for (var repo : subrepos) {\n                if (!repo.equals(subrepo) && repo.startsWith(subrepo)) {\n                    subSubRepos.add(repo);\n                }\n            }\n            if (!subSubRepos.isEmpty()) {\n                var subSubTreesFile = treesFile(root.resolve(subrepo), isMercurial);\n                Files.write(subSubTreesFile,\n                            subSubRepos.stream()\n                                       .map(subrepo::relativize)\n                                       .map(Path::toString)\n                                       .sorted()\n                                       .collect(Collectors.toList()));\n            }\n        }\n\n        return subrepos;\n    }\n\n    private static List<Path> trees(Path root, boolean isMercurial) throws IOException {\n        var file = treesFile(root, isMercurial);\n        if (Files.exists(file)) {\n            var lines = Files.readAllLines(file);\n            return lines.stream().map(Path::of).collect(Collectors.toList());\n        }\n\n        return null;\n    }\n\n    private static void treconfigure(Path root, boolean isMercurial) throws IOException {\n        var existing = trees(root, isMercurial);\n        if (existing != null) {\n            for (var subrepo : existing) {\n                var subRoot = root.resolve(subrepo);\n                var file = treesFile(subRoot, isMercurial);\n                Files.deleteIfExists(file);\n            }\n        }\n        tconfig(root, isMercurial);\n    }\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        if (args.length == 1 && args[0].equals(\"--version\")) {\n            System.out.println(\"git-trees version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        if (args.length == 1 && (args[0].equals(\"-h\") || args[0].equals(\"--help\"))) {\n            System.out.println(\"usage: git-trees [options] <COMMAND> [<ARGS>]\");\n            System.out.println(\"\\t<COMMAND>\\tA git/hg command to run once for each repo, or\");\n            System.out.println(\"\\ttreconfigure\\tto pick up changes in the tree hierarchy\");\n            System.out.println(\"\\t-m, --mercurial\\tDeprecated: force use of mercurial\");\n            System.out.println(\"\\t-h, --help     \\tShow this help text\");\n            System.out.println(\"\\t    --version  \\tPrint the version of this tool\");\n            System.exit(0);\n        }\n\n        HttpProxy.setup();\n\n        var isMercurial = args.length > 0 && (args[0].equals(\"--mercurial\") || args[0].equals(\"-m\"));\n        var isReconfigure = isMercurial ?\n            args.length > 1 && args[1].equals(\"treconfigure\") :\n            args.length > 0 && args[0].equals(\"treconfigure\");\n\n        var root = root(isMercurial);\n        if (isReconfigure) {\n            treconfigure(root, isMercurial);\n            return;\n        }\n\n        var trees = trees(root, isMercurial);\n        if (trees == null) {\n            trees = tconfig(root, isMercurial);\n        }\n\n        var command = new ArrayList<String>();\n        command.add(isMercurial ? \"hg\" : \"git\");\n        for (var i = isMercurial ? 1 : 0; i < args.length; i++) {\n            command.add(args[i]);\n        }\n        var pb = new ProcessBuilder(command);\n        pb.inheritIO();\n\n        System.out.println(\"[\" + root.toString() + \"]\");\n        pb.directory(root.toFile());\n        var ret = pb.start().waitFor();\n\n        for (var path : trees) {\n            var subroot = root.resolve(path);\n            if (isRepository(subroot, isMercurial)) {\n                System.out.println();\n                System.out.println(\"[\" + root.resolve(path).toString() + \"]\");\n                pb.directory(subroot.toFile());\n                ret += pb.start().waitFor();\n            }\n        }\n\n        System.exit(ret);\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/GitWebrev.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.proxy.HttpProxy;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.webrev.*;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.net.URISyntaxException;\nimport java.net.http.HttpClient;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.logging.Level;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class GitWebrev {\n    private static final List<String> KNOWN_JBS_PROJECTS =\n        List.of(\"JDK\", \"CODETOOLS\", \"SKARA\", \"JMC\");\n    private static void clearDirectory(Path directory) {\n        try (var paths = Files.walk(directory)) {\n            paths.map(Path::toFile)\n                 .sorted(Comparator.reverseOrder())\n                 .forEach(File::delete);\n        } catch (IOException io) {\n            throw new RuntimeException(io);\n        }\n    }\n\n    private static String arg(String name, Arguments args, ReadOnlyRepository repo) throws IOException {\n        if (args.contains(name)) {\n            return args.get(name).asString();\n        }\n\n        var config = repo.config(\"webrev.\" + name);\n        if (config.size() == 1) {\n            return config.get(0);\n        }\n\n        return null;\n    }\n\n    private static void die(String message) {\n        System.err.println(message);\n        System.exit(1);\n    }\n\n    private static Hash resolve(ReadOnlyRepository repo, String ref) {\n        var message = \"error: could not resolve reference '\" + ref + \"'\";\n        try {\n            var hash = repo.resolve(ref);\n            if (!hash.isPresent()) {\n                die(message);\n            }\n            return hash.get();\n        } catch (IOException e) {\n            die(message);\n            return null; // impossible\n        }\n    }\n\n    private static boolean isDigit(char c) {\n        return Character.isDigit(c);\n    }\n\n    private static void generate(String[] args) throws IOException {\n        var flags = List.of(\n            Option.shortcut(\"r\")\n                  .fullname(\"rev\")\n                  .describe(\"REV\")\n                  .helptext(\"Compare against a specified base revision (alias for --base)\")\n                  .optional(),\n            Option.shortcut(\"o\")\n                  .fullname(\"output\")\n                  .describe(\"DIR\")\n                  .helptext(\"Output directory\")\n                  .optional(),\n            Option.shortcut(\"u\")\n                  .fullname(\"username\")\n                  .describe(\"NAME\")\n                  .helptext(\"Use specified username instead of 'guessing' one\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"upstream\")\n                  .describe(\"URL\")\n                  .helptext(\"The URL to the upstream repository\")\n                  .optional(),\n            Option.shortcut(\"t\")\n                  .fullname(\"title\")\n                  .describe(\"TITLE\")\n                  .helptext(\"The title of the webrev\")\n                  .optional(),\n            Option.shortcut(\"c\")\n                  .fullname(\"cr\")\n                  .describe(\"CR#\")\n                  .helptext(\"Include link to the CR (aka bugid) in the main page\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"remote\")\n                  .describe(\"NAME\")\n                  .helptext(\"Use specified remote for calculating outgoing changes\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"base\")\n                  .describe(\"REV\")\n                  .helptext(\"Use specified revision as base for comparison\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"head\")\n                  .describe(\"REV\")\n                  .helptext(\"Use specified revision as head for comparison\")\n                  .optional(),\n            Option.shortcut(\"s\")\n                  .fullname(\"similarity\")\n                  .describe(\"SIMILARITY\")\n                  .helptext(\"Guess renamed files by similarity (0 - 100)\")\n                  .optional(),\n            Switch.shortcut(\"b\")\n                  .fullname(\"\")\n                  .helptext(\"Do not ignore changes in whitespace (always true)\")\n                  .optional(),\n            Switch.shortcut(\"m\")\n                  .fullname(\"mercurial\")\n                  .helptext(\"Deprecated: force use of mercurial\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"json\")\n                  .helptext(\"Generate JSON instead of HTML\")\n                  .optional(),\n            Switch.shortcut(\"C\")\n                  .fullname(\"no-comments\")\n                  .helptext(\"Do not show comments\")\n                  .optional(),\n            Switch.shortcut(\"N\")\n                  .fullname(\"no-outgoing\")\n                  .helptext(\"Do not compare against remote, use only 'status'\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"verbose\")\n                  .helptext(\"Turn on verbose output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"debug\")\n                  .helptext(\"Turn on debugging output\")\n                  .optional(),\n            Switch.shortcut(\"v\")\n                  .fullname(\"version\")\n                  .helptext(\"Print the version of this tool\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"quiet\")\n                  .helptext(\"Do not print webrev link after executing successfully\")\n                  .optional());\n\n        var inputs = List.of(\n            Input.position(0)\n                 .describe(\"FILE\")\n                 .singular()\n                 .optional());\n\n        var parser = new ArgumentParser(\"git webrev\", flags, inputs);\n        var arguments = parser.parse(args);\n\n        var version = Version.fromManifest().orElse(\"unknown\");\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-webrev version: \" + version);\n            System.exit(0);\n        }\n\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            Logging.setup(level);\n        } else {\n            Logging.setup(Level.WARNING);\n        }\n\n        var cwd = Paths.get(\"\").toAbsolutePath();\n        var repository = ReadOnlyRepository.get(cwd);\n        if (!repository.isPresent()) {\n            System.err.println(String.format(\"error: %s is not a repository\", cwd.toString()));\n            System.exit(1);\n        }\n        var repo = repository.get();\n        var isMercurial = arguments.contains(\"mercurial\");\n\n\n        URI upstreamPullPath = null;\n        URI originPullPath = null;\n        var remotes = repo.remotes();\n        if (remotes.contains(\"upstream\")) {\n            upstreamPullPath = Remote.toWebURI(repo.pullPath(\"upstream\"));\n        }\n        if (remotes.contains(\"origin\") || remotes.contains(\"default\")) {\n            var remote = isMercurial ? \"default\" : \"origin\";\n            originPullPath = Remote.toWebURI(repo.pullPath(remote));\n        }\n\n        if (arguments.contains(\"json\") &&\n            (upstreamPullPath == null || originPullPath == null)) {\n            System.err.println(\"error: --json requires remotes 'upstream' and 'origin' to be present\");\n            System.exit(1);\n        }\n\n        var upstream = arg(\"upstream\", arguments, repo);\n        if (upstream == null) {\n            if (upstreamPullPath != null) {\n                var host = upstreamPullPath.getHost();\n                if (host != null && host.endsWith(\"openjdk.org\")) {\n                    upstream = upstreamPullPath.toString();\n                } else if (host != null && host.equals(\"github.com\")) {\n                    var path = upstreamPullPath.getPath();\n                    if (path != null && path.startsWith(\"/openjdk/\")) {\n                        upstream = upstreamPullPath.toString();\n                    }\n                }\n            } else if (originPullPath != null) {\n                var host = originPullPath.getHost();\n                if (host != null && host.endsWith(\"openjdk.org\")) {\n                    upstream = originPullPath.toString();\n                } else if (host != null && host.equals(\"github.com\")) {\n                    var path = originPullPath.getPath();\n                    if (path != null && path.startsWith(\"/openjdk/\")) {\n                        upstream = originPullPath.toString();\n                    }\n                }\n            }\n        }\n        var upstreamURL = upstream;\n\n        var noOutgoing = arguments.contains(\"no-outgoing\");\n        if (!noOutgoing) {\n            var config = repo.config(\"webrev.no-outgoing\");\n            if (config.size() == 1) {\n                var enabled = Set.of(\"TRUE\", \"ON\", \"1\", \"ENABLED\");\n                noOutgoing = enabled.contains(config.get(0).toUpperCase());\n            }\n        }\n\n        var comments = !arguments.contains(\"no-comments\");\n        var quiet = arguments.contains(\"quiet\");\n        if (arguments.contains(\"base\") && arguments.contains(\"rev\")) {\n            System.err.println(\"error: cannot combine --base and --rev options\");\n            System.exit(1);\n        }\n        if (arguments.contains(\"head\") && arguments.contains(\"rev\")) {\n            System.err.println(\"error: cannot combine --head and --rev options\");\n            System.exit(1);\n        }\n        if (arguments.contains(\"head\") && !arguments.contains(\"base\")) {\n            System.err.println(\"error: cannot use --head without using --base\");\n            System.exit(1);\n        }\n\n        Hash rev = null;\n        var revArg = ForgeUtils.getOption(\"rev\", arguments);\n        if (revArg != null) {\n            rev = resolve(repo, revArg);\n        }\n        if (rev == null && !(arguments.contains(\"base\") && arguments.contains(\"head\"))) {\n            if (isMercurial) {\n                resolve(repo, noOutgoing ? \"tip\" : \"min(outgoing())^\");\n            } else {\n                if (noOutgoing) {\n                    rev = resolve(repo, \"HEAD\");\n                } else {\n                    var currentUpstreamBranch = repo.currentBranch().flatMap(b -> {\n                        try {\n                            return repo.upstreamFor(b);\n                        } catch (IOException e) {\n                            throw new UncheckedIOException(e);\n                        }\n                    });\n                    if (currentUpstreamBranch.isPresent()) {\n                        rev = resolve(repo, currentUpstreamBranch.get());\n                    } else {\n                        String remote = arg(\"remote\", arguments, repo);\n                        if (remote == null) {\n                            if (remotes.size() == 0) {\n                                System.err.println(\"error: no remotes present, cannot figure out outgoing changes\");\n                                System.err.println(\"       Use --rev to specify revision to compare against\");\n                                System.exit(1);\n                            } else if (remotes.size() == 1) {\n                                remote = remotes.get(0);\n                            } else {\n                                if (remotes.contains(\"origin\")) {\n                                    remote = \"origin\";\n                                } else {\n                                    System.err.println(\"error: multiple remotes without origin remote present, cannot figure out outgoing changes\");\n                                    System.err.println(\"       Use --rev to specify revision to compare against\");\n                                    System.exit(1);\n                                }\n                            }\n                        }\n\n                        var head = repo.head();\n                        var shortestDistance = -1;\n                        var pullPath = repo.pullPath(remote);\n                        for (var branch : repo.branches(remote)) {\n                            var branchHead = repo.resolve(branch).orElseThrow();\n                            var mergeBase = repo.mergeBase(branchHead, head);\n                            var distance = repo.commitMetadata(mergeBase, head).size();\n                            if (shortestDistance == -1 || distance < shortestDistance) {\n                                rev = mergeBase;\n                                shortestDistance = distance;\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        var base = rev;\n        var baseArg = ForgeUtils.getOption(\"base\", arguments);\n        if (baseArg != null) {\n            base = resolve(repo, baseArg);\n        }\n        Hash head = null;\n        var headArg = ForgeUtils.getOption(\"head\", arguments);\n        if (headArg != null) {\n            head = resolve(repo, headArg);\n        }\n\n        String issue = ForgeUtils.getOption(\"cr\", arguments);\n        if (issue != null) {\n            if (issue.startsWith(\"http\")) {\n                var uri = URI.create(issue);\n                issue = Path.of(uri.getPath()).getFileName().toString();\n            } else if (isDigit(issue.charAt(0))) {\n                issue = \"JDK-\" + issue;\n            }\n        }\n        if (issue == null) {\n            var pattern = Pattern.compile(\"(?:(\" + String.join(\"|\", KNOWN_JBS_PROJECTS) + \")-)?([0-9]+).*\");\n            var currentBranch = repo.currentBranch();\n            if (currentBranch.isPresent()) {\n                var branchName = currentBranch.get().name().toUpperCase();\n                var m = pattern.matcher(branchName);\n                if (m.matches()) {\n                    var project = m.group(1);\n                    if (project == null) {\n                        project = \"JDK\";\n                    }\n                    var id = m.group(2);\n                    issue = project + \"-\" + id;\n                }\n            }\n        }\n\n        var out = arg(\"output\", arguments, repo);\n        if (out == null) {\n            out = \"webrev\";\n        }\n        var output = Path.of(out);\n\n        String title = ForgeUtils.getOption(\"title\", arguments);\n        if (title == null && issue != null) {\n            try {\n                var uri = new URI(issue);\n                title = Path.of(uri.getPath()).getFileName().toString();\n            } catch (URISyntaxException e) {\n                title = null;\n            }\n        }\n        if (title == null && upstream != null) {\n            var index = upstream.lastIndexOf(\"/\");\n            if (index != -1 && index + 1 < upstream.length()) {\n                title = upstream.substring(index + 1);\n            }\n        }\n        if (title == null) {\n            title = Path.of(\"\").toAbsolutePath().getFileName().toString();\n        }\n\n        var username = arg(\"username\", arguments, repo);\n        if (username == null) {\n            username = repo.username().orElse(System.getProperty(\"user.name\"));\n        }\n        var author = Author.fromString(username);\n\n        if (Files.exists(output)) {\n            clearDirectory(output);\n        }\n\n        List<Path> files = List.of();\n        if (arguments.at(0).isPresent()) {\n            var path = arguments.at(0).via(Path::of);\n            if (path.equals(Path.of(\"-\"))) {\n                var reader = new BufferedReader(new InputStreamReader(System.in));\n                files = reader.lines().map(Path::of).collect(Collectors.toList());\n            } else if (Files.exists(path) && !Files.isDirectory(path) && Files.isReadable(path)) {\n                files = Files.readAllLines(path).stream().map(Path::of).collect(Collectors.toList());\n            } else {\n                System.err.println(String.format(\"error: '%s' is not a readable file\", path));\n                System.exit(1);\n            }\n        }\n\n        var similarity = 90;\n        try {\n            var similarityArg = arg(\"similarity\", arguments, repo);\n            if (similarityArg != null) {\n                var value = Integer.parseInt(similarityArg);\n                if (value < 0 || value > 100) {\n                    System.err.println(\"error: --similarity must be a number between 0 and 100\");\n                    System.exit(1);\n                }\n                similarity = value;\n            }\n        } catch (NumberFormatException e) {\n                System.err.println(\"error: --similarity must be a number between 0 and 100\");\n                System.exit(1);\n        }\n\n        var jbs = \"https://bugs.openjdk.org/browse/\";\n        var issueParts = issue != null ? issue.split(\"-\") : new String[0];\n        var jbsProject = issueParts.length == 2 && KNOWN_JBS_PROJECTS.contains(issueParts[0])?\n            issueParts[0] : \"JDK\";\n        if (arguments.contains(\"json\")) {\n            if (head == null) {\n                head = repo.head();\n            }\n            var upstreamName = upstreamPullPath.getPath().substring(1);\n            var originName = originPullPath.getPath().substring(1);\n            try {\n                Webrev.repository(repo)\n                      .output(output)\n                      .upstream(upstreamPullPath, upstreamName)\n                      .fork(originPullPath, originName)\n                      .similarity(similarity)\n                      .generateJSON(base, head);\n            } catch (DiffTooLargeException e) {\n                System.out.println(\"Webrev is not available because diff is too large.\");\n            }\n        } else {\n            try {\n                Webrev.repository(repo)\n                      .output(output)\n                      .title(title)\n                      .upstream(upstream)\n                      .username(author.name())\n                      .commitLinker(hash -> upstreamURL == null ? null : upstreamURL + \"/commit/\" + hash)\n                      .issueLinker(id -> jbs + (isDigit(id.charAt(0)) ? jbsProject + \"-\" : \"\") + id)\n                      .issue(issue)\n                      .version(version)\n                      .files(files)\n                      .similarity(similarity)\n                      .comments(comments)\n                      .generate(base, head);\n            } catch (DiffTooLargeException e) {\n                System.out.println(\"Webrev is not available because diff is too large.\");\n            }\n        }\n        if (!quiet) {\n            System.out.println(\"Webrev executed successfully, details are in the link below:\");\n            System.out.println(output.resolve(\"index.html\").toUri());\n        }\n    }\n\n    private static void apply(String[] args) throws Exception {\n        var inputs = List.of(\n            Input.position(0)\n                 .describe(\"webrev url\")\n                 .singular()\n                 .required());\n\n        var parser = new ArgumentParser(\"git webrev apply\", List.of(), inputs);\n        var arguments = parser.parse(args);\n\n        var cwd = Paths.get(\"\").toAbsolutePath();\n        var repository = Repository.get(cwd).orElseGet(() -> {\n            System.err.println(String.format(\"error: %s is not a repository\", cwd.toString()));\n            System.exit(1);\n            return null;\n        });\n\n        var inputString = arguments.at(0).asString();\n        var webrevMetaData = WebrevMetaData.from(URI.create(inputString));\n        var patchFileURI = webrevMetaData.patchURI()\n                .orElseThrow(() -> new IllegalStateException(\"Could not find patch file in webrev\"));\n        var patchFile = downloadPatchFile(patchFileURI);\n\n        repository.apply(patchFile, false);\n    }\n\n    private static Path downloadPatchFile(URI uri) throws IOException, InterruptedException {\n        var client = HttpClient.newHttpClient();\n        var patchFile = Files.createTempFile(\"patch\", \".patch\");\n        var patchFileRequest = HttpRequest.newBuilder()\n                .uri(uri)\n                .build();\n        client.send(patchFileRequest, HttpResponse.BodyHandlers.ofFile(patchFile));\n        return patchFile;\n    }\n\n    public static void main(String[] args) throws Exception {\n        var commands = List.of(\n                    Default.name(\"generate\")\n                           .helptext(\"generate a webrev\")\n                           .main(GitWebrev::generate),\n                    Command.name(\"apply\")\n                           .helptext(\"apply a webrev from a webrev url\")\n                           .main(GitWebrev::apply)\n                );\n        HttpProxy.setup();\n\n        var parser = new MultiCommandParser(\"git webrev\", commands, false);\n        var command = parser.parse(args);\n        command.execute();\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/JCheckCLIVisitor.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.jcheck.*;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\nclass JCheckCLIVisitor implements IssueVisitor {\n    private final Set<String> ignore;\n    private final boolean isMercurial;\n    private final boolean isLax;\n    private boolean hasDisplayedErrors;\n\n    public JCheckCLIVisitor() {\n        this(Set.of(), false, false);\n    }\n\n    public JCheckCLIVisitor(Set<String> ignore, boolean isMercurial, boolean isLax) {\n        this.ignore = ignore;\n        this.isMercurial = isMercurial;\n        this.isLax = isLax;\n        this.hasDisplayedErrors = false;\n    }\n\n    private String println(Issue i, String message) {\n        var prefix = \"[\" + i.check().name() + \"] \" + i.severity() + \": \";\n        System.out.print(prefix);\n        System.out.println(message);\n        return prefix;\n    }\n\n    private String println(CommitIssue i, String message) {\n        String prefix = \"[\" + i.check().name() + \"] \" + i.severity() + \": \";\n        Hash hash = i.commit().hash();\n        if (hash.hex().equals(\"staged\") || hash.hex().equals(\"working-tree\")) {\n            prefix += hash.hex() + \": \";\n        } else {\n            prefix += i.commit().hash().abbreviate() + \": \";\n        }\n        System.out.print(prefix);\n        System.out.println(message);\n        return prefix;\n    }\n\n    public void visit(DuplicateIssuesIssue i) {\n        if (!ignore.contains(i.check().name())) {\n            var id = i.issue().id();\n            var hash = i.commit().hash().abbreviate();\n            var other = i.hashes()\n                         .stream()\n                         .map(Hash::abbreviate)\n                         .map(s -> \"         - \" + s)\n                         .collect(Collectors.toList());\n            println(i, \"issue id '\" + id + \"' in commit \" + hash + \" is already used in commits:\");\n            other.forEach(System.out::println);\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(TagIssue i) {\n        if (!ignore.contains(i.check().name()) && !isLax) {\n            println(i, \"illegal tag name: \" + i.tag().name());\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(BranchIssue i) {\n        if (!ignore.contains(i.check().name())) {\n            println(i, \"illegal branch name: \" + i.branch().name());\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(SelfReviewIssue i) {\n        if (!ignore.contains(i.check().name()) && !isLax) {\n            println(i, \"self-reviews are not allowed\");\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(TooFewReviewersIssue i) {\n        if (!ignore.contains(i.check().name()) && !isLax) {\n            var required = i.numRequired();\n            var actual = i.numActual();\n            var reviewers = required == 1 ? \" reviewer\" : \" reviewers\";\n            println(i, required + reviewers + \" required, found \" + actual);\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(InvalidReviewersIssue i) {\n        if (!ignore.contains(i.check().name())) {\n            var invalid = String.join(\", \", i.invalid());\n            var wording = i.invalid().size() == 1 ? \" is\" : \" are\";\n            println(i, invalid + wording + \" not part of OpenJDK\");\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(MergeMessageIssue i) {\n        if (!ignore.contains(i.check().name()) && !isLax) {\n            println(i, \"merge commits should only use the commit message '\" + i.expected() + \"'\");\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(HgTagCommitIssue i) {\n        if (!ignore.contains(i.check().name()) && !isLax) {\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n            switch (i.error()) {\n                case TOO_MANY_LINES:\n                    println(i, \"message should only be one line\");\n                    return;\n                case BAD_FORMAT:\n                    println(i, \"message should be of format 'Added tag <tag> for changeset <hash>'\");\n                    return;\n                case TOO_MANY_CHANGES:\n                    println(i, \"should only add one line to .hgtags\");\n                    return;\n                case TAG_DIFFERS:\n                    println(i, \"tag differs in commit message and .hgtags\");\n                    return;\n            }\n        }\n    }\n\n    public void visit(CommitterIssue i) {\n        if (!ignore.contains(i.check().name())) {\n            var committer = i.commit().committer().name();\n            var project = i.project().name();\n            println(i, committer + \" is not committer in project \" + project);\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    private static class WhitespaceRange {\n        private final WhitespaceIssue.Whitespace kind;\n        private final int start;\n        private final int end;\n\n        public WhitespaceRange(WhitespaceIssue.Whitespace kind, int start, int end) {\n            this.kind = kind;\n            this.start = start;\n            this.end = end;\n        }\n\n        public WhitespaceIssue.Whitespace kind() {\n            return kind;\n        }\n\n        public int start() {\n            return start;\n        }\n\n        public int end() {\n            return end;\n        }\n    }\n\n    private static List<WhitespaceRange> ranges(List<WhitespaceIssue.Error> errors) {\n        if (errors.size() == 1) {\n            var res = new ArrayList<WhitespaceRange>();\n            res.add(new WhitespaceRange(errors.get(0).kind(), errors.get(0).index(), errors.get(0).index()));\n            return res;\n        }\n\n        var merged = new ArrayList<WhitespaceRange>();\n        var start = errors.get(0);\n        var end = start;\n        for (int i = 1; i < errors.size(); i++) {\n            var e = errors.get(i);\n            if (e.index() == (end.index() + 1) && e.kind() == end.kind()) {\n                end = e;\n            } else {\n                merged.add(new WhitespaceRange(e.kind(), start.index(), end.index()));\n                start = e;\n            }\n        }\n\n        return merged;\n    }\n\n    public void visit(WhitespaceIssue i) {\n        if (!ignore.contains(i.check().name()) && !isLax) {\n            var pos = i.path() + \":\" + i.row();\n            var prefix = println(i, i.describe() + \" in \" + pos);\n            var indent = prefix.replaceAll(\".\", \" \");\n            System.out.println(indent + i.escapeLine());\n            System.out.println(indent + i.hints());\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(MessageIssue i) {\n        if (!ignore.contains(i.check().name()) && !isLax) {\n            println(i, \"contains additional lines in commit message\");\n            for (var line : i.message().additional()) {\n                System.out.println(\"> \" + line);\n            }\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(MessageWhitespaceIssue i) {\n        if (!ignore.contains(i.check().name()) && !isLax) {\n            String desc = null;\n            if (i.kind().isTab()) {\n                desc = \"tab\";\n            } else if (i.kind().isCR()) {\n                desc = \"carriage-return\";\n            } else {\n                desc = \"trailing whitespace\";\n            }\n            println(i, \"contains \" + desc + \" on line \" + i.line() + \" in commit message:\");\n            System.out.println(\"> \" + i.commit().message().get(i.line() - 1));\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(IssuesIssue i) {\n        if (!ignore.contains(i.check().name()) && !isLax) {\n            println(i, \"missing reference to JBS issue in commit message\");\n            for (var line : i.commit().message()) {\n                System.out.println(\"> \" + line);\n            }\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(ExecutableIssue i) {\n        if (!ignore.contains(i.check().name())) {\n            println(i, \"file \" + i.path() + \" is executable\");\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(SymlinkIssue i) {\n        if (!ignore.contains(i.check().name())) {\n            println(i, \"file \" + i.path() + \" is symbolic link\");\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(AuthorNameIssue i) {\n        if (!ignore.contains(i.check().name())) {\n            println(i, \"missing author name\");\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(AuthorEmailIssue i) {\n        if (!ignore.contains(i.check().name()) && !isMercurial) {\n            println(i, \"missing author email\");\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(CommitterNameIssue i) {\n        if (!ignore.contains(i.check().name())) {\n            println(i, \"missing committer name\");\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(CommitterEmailIssue i) {\n        if (!ignore.contains(i.check().name()) && !isMercurial) {\n            var domain = i.expectedDomain();\n            println(i, \"missing committer email from domain \" + domain);\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public void visit(BinaryIssue i) {\n        if (!ignore.contains(i.check().name())) {\n            println(i, \"adds binary file: \" + i.path().toString());\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    @Override\n    public void visit(ProblemListsIssue i) {\n        if (!ignore.contains(i.check().name())) {\n            println(i, i.issue() + \" is used in problem lists \" + i.files());\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    @Override\n    public void visit(IssuesTitleIssue i) {\n        if (!ignore.contains(i.check().name()) && !isLax) {\n            if (!i.issuesWithTrailingPeriod().isEmpty()) {\n                println(i, \"Found trailing period in issue title for \" + String.join(\", \", i.issuesWithTrailingPeriod()));\n            }\n            if (!i.issuesWithLeadingLowerCaseLetter().isEmpty()) {\n                println(i, \"Found leading lowercase letter in issue title for \" + String.join(\", \", i.issuesWithLeadingLowerCaseLetter()));\n            }\n            for (var line : i.commit().message()) {\n                System.out.println(\"> \" + line);\n            }\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    @Override\n    public void visit(CopyrightFormatIssue i) {\n        if (!ignore.contains(i.check().name()) && !isLax) {\n            for (var entry : i.filesWithCopyrightFormatIssue().entrySet()) {\n                println(i, \"Found copyright format issue for \" + entry.getKey() + \" in [\" + String.join(\", \", entry.getValue()) + \"]\");\n            }\n            for (var entry : i.filesWithCopyrightMissingIssue().entrySet()) {\n                println(i, \"Can't find copyright header for \" + entry.getKey() + \" in [\" + String.join(\", \", entry.getValue()) + \"]\");\n            }\n            for (var line : i.commit().message()) {\n                System.out.println(\"> \" + line);\n            }\n            hasDisplayedErrors = i.severity() == Severity.ERROR;\n        }\n    }\n\n    public boolean hasDisplayedErrors() {\n        return hasDisplayedErrors;\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/Logging.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport java.util.logging.*;\n\npublic class Logging {\n    private static Logger log;\n\n    public static void setup(Level level) {\n        setup(level, \"\");\n    }\n\n    public static void setup(Level level, String component) {\n        LogManager.getLogManager().reset();\n        log = level == Level.FINE ?\n            Logger.getLogger(\"org.openjdk.skara\" + \".\" + component) :\n            Logger.getLogger(\"org.openjdk.skara\");\n        log.setLevel(level);\n\n        ConsoleHandler handler = new ConsoleHandler();\n        handler.setFormatter(new MinimalFormatter());\n        handler.setLevel(level);\n        log.addHandler(handler);\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/MinimalFormatter.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport java.util.logging.Formatter;\nimport java.util.logging.LogRecord;\nimport java.util.logging.Level;\n\nclass MinimalFormatter extends Formatter {\n    public String format(LogRecord record) {\n        var prefix = \"\";\n        var level = record.getLevel();\n        if (level.equals(Level.SEVERE)) {\n            prefix = \"error: \";\n        } else if (level.equals(Level.WARNING)) {\n            prefix = \"warning: \";\n        } else if (level.equals(Level.INFO)) {\n            prefix = \"info: \";\n        }\n        return prefix + record.getMessage() + \"\\n\";\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/Remote.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.file.Path;\nimport java.nio.file.Files;\nimport java.nio.charset.StandardCharsets;\n\npublic class Remote {\n    private static URI sshCanonicalize(URI uri) throws IOException {\n        var arg = uri.getUserInfo() == null ? uri.getHost() : uri.getUserInfo() + \"@\" + uri.getHost();\n        var pb = new ProcessBuilder(\"ssh\", \"-G\", arg);\n        pb.redirectOutput(ProcessBuilder.Redirect.PIPE);\n        pb.redirectError(ProcessBuilder.Redirect.DISCARD);\n        var p = pb.start();\n\n        var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);\n        try {\n            var res = p.waitFor();\n            if (res != 0) {\n                throw new IOException(\"ssh -G \" + arg + \" exited with non-zero exit code: \" + res);\n            }\n        } catch (InterruptedException e) {\n            throw new IOException(e);\n        }\n\n        String hostname = null;\n        String username = null;\n        for (var line : output.split(\"\\n\")) {\n            var parts = line.trim().split(\" \");\n            if (parts.length == 2) {\n                var key = parts[0];\n                var value = parts[1];\n                if (key.equals(\"hostname\")) {\n                    hostname = value;\n                } else if (key.equals(\"user\")) {\n                    username = value;\n                }\n            }\n        }\n\n        if (hostname == null) {\n            throw new IOException(\"ssh -G \" + arg + \" did not output a hostname\");\n        }\n\n        return username == null ?\n            URI.create(\"ssh://\" + hostname + uri.getPath()) :\n            URI.create(\"ssh://\" + username + \"@\" + hostname + uri.getPath());\n    }\n\n    public static URI toWebURI(String remotePath) throws IOException {\n        var uri = toURI(remotePath);\n        if (uri.getScheme().equals(\"file://\")) {\n            throw new IOException(\"Cannot create web URI for file path: \" + uri.toString());\n        }\n\n        // Use https://, drop eventual .git from path and drop authority\n        var path = uri.getPath();\n        if (path.endsWith(\".git\")) {\n            path = path.substring(0, path.length() - \".git\".length());\n        }\n        return URI.create(\"https://\" + uri.getHost() + path);\n    }\n\n    public static URI toURI(String remotePath) throws IOException {\n        return toURI(remotePath, false);\n    }\n\n    public static URI toURI(String remotePath, boolean canonicalize) throws IOException {\n        if (remotePath.startsWith(\"git+\")) {\n            remotePath = remotePath.substring(\"git+\".length());\n        }\n        if (remotePath.startsWith(\"http://\") ||\n            remotePath.startsWith(\"https://\") ||\n            remotePath.startsWith(\"ssh://\") ||\n            remotePath.startsWith(\"file://\") ||\n            remotePath.startsWith(\"git://\")) {\n            return URI.create(remotePath);\n        }\n\n        var indexOfColon = remotePath.indexOf(':');\n        var indexOfSlash = remotePath.indexOf('/');\n        if (indexOfColon != -1) {\n            if (indexOfSlash == -1 || indexOfColon < indexOfSlash) {\n                var uri = URI.create(\"ssh://\" + remotePath.replace(\":\", \"/\"));\n                return canonicalize ? sshCanonicalize(uri) : uri;\n            }\n        }\n\n        throw new IOException(\"Cannot construct URI for \" + remotePath);\n    }\n\n    public static URI canonicalize(URI uri) throws IOException {\n        if (uri.getScheme().equals(\"ssh\")) {\n            return sshCanonicalize(uri);\n        }\n        return uri;\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/SkaraDebug.java",
    "content": "/*\n * Copyright (c) 2020, 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.cli.debug.*;\nimport org.openjdk.skara.proxy.HttpProxy;\n\nimport java.util.List;\n\npublic class SkaraDebug {\n    public static void main(String[] args) throws Exception {\n        var commands = List.of(\n                Default.name(\"help\")\n                       .helptext(\"show help text\")\n                       .main(SkaraDebugHelp::main),\n                Command.name(\"import-hg\")\n                       .helptext(\"import a hg repository\")\n                       .main(GitOpenJDKImport::main),\n                Command.name(\"import-git\")\n                       .helptext(\"import git repository\")\n                       .main(HgOpenJDKImport::main),\n                Command.name(\"verify-import\")\n                       .helptext(\"verify imported repository\")\n                       .main(GitVerifyImport::main),\n                Command.name(\"mlrules\")\n                       .helptext(\"create and verify jdk mailing list filter rules\")\n                       .main(GitMlRules::main),\n                Command.name(\"issue-redecorate\")\n                       .helptext(\"reapplies the hgupdate-sync label to the given ussue\")\n                       .main(IssueRedecorate::main),\n                Command.name(\"commit-comments\")\n                       .helptext(\"Lists recent commit comments for a repository\")\n                       .main(GitCommitComments::main)\n        );\n\n        HttpProxy.setup();\n\n        var parser = new MultiCommandParser(\"skara debug\", commands, true);\n        var command = parser.parse(args);\n        command.execute();\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/debug/GitMlRules.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.debug;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.cli.Logging;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.process.Process;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.*;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.net.http.*;\nimport java.nio.file.*;\nimport java.time.*;\nimport java.time.format.DateTimeFormatter;\nimport java.time.temporal.ChronoUnit;\nimport java.util.*;\nimport java.util.function.Function;\nimport java.util.logging.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.*;\n\npublic class GitMlRules {\n    private final static Pattern rfrSubject = Pattern.compile(\"(?:^Subject: .*?)([78]\\\\d{6})\", Pattern.MULTILINE);\n    private final static Pattern rfrSubjectOrIssue = Pattern.compile(\"(?:(?:^Subject: .*?)|(?:JDK-))([78]\\\\\\\\d{6})\", Pattern.MULTILINE);\n    private final static Logger log = Logger.getLogger(\"org.openjdk.skara.mlrules\");\n\n    private static Pattern reviewPattern = rfrSubject;\n    private static int daysOfHistory = 30;\n    private static int filterDivider = 5;\n    private static Pattern listFilterPattern = Pattern.compile(\".*\");\n\n    static final List<Flag> flags = List.of(\n            Option.shortcut(\"d\")\n                  .fullname(\"days\")\n                  .describe(\"DAYS\")\n                  .helptext(\"Number of days to look back\")\n                  .optional(),\n            Option.shortcut(\"f\")\n                  .fullname(\"filter\")\n                  .describe(\"DIVIDER\")\n                  .helptext(\"Divider for filter threshold\")\n                  .optional(),\n            Option.shortcut(\"o\")\n                  .fullname(\"output\")\n                  .describe(\"FILE\")\n                  .helptext(\"Name of file to write output to\")\n                  .optional(),\n            Option.shortcut(\"v\")\n                  .fullname(\"verify\")\n                  .describe(\"CONFIG-FILE\")\n                  .helptext(\"Name of json config file to verify against\")\n                  .optional(),\n            Option.shortcut(\"l\")\n                  .fullname(\"lists\")\n                  .describe(\"PATTERN\")\n                  .helptext(\"Regular expression matching mailing lists to include when verifying (default all known)\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"verbose\")\n                  .helptext(\"Turn on verbose output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"debug\")\n                  .helptext(\"Turn on debugging output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"relaxed\")\n                  .helptext(\"Use more relaxed matching when searching for reviews\")\n                  .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n            Input.position(0)\n                 .describe(\"repository root or files\")\n                 .trailing()\n                 .required()\n    );\n\n    private static String archivePageName(ZonedDateTime month) {\n        return DateTimeFormatter.ofPattern(\"yyyy-MMMM\", Locale.US).format(month);\n    }\n\n    private static List<ZonedDateTime> monthRange(Duration maxAge) {\n        var now = ZonedDateTime.now();\n        var start = now.minus(maxAge);\n        List<ZonedDateTime> ret = new ArrayList<>();\n\n        while (start.isBefore(now)) {\n            ret.add(start);\n            var next = start.plus(Duration.ofDays(1));\n            while (start.getMonthValue() == next.getMonthValue()) {\n                next = next.plus(Duration.ofDays(1));\n            }\n            start = next;\n        }\n        return ret;\n    }\n\n    private static Set<String> archivePageNames() {\n        return monthRange(Duration.of(daysOfHistory, ChronoUnit.DAYS)).stream()\n                                                                      .map(GitMlRules::archivePageName)\n                                                                      .collect(Collectors.toSet());\n    }\n\n    private static Set<String> listSubjects(HttpClient client, String list) {\n        var tmpFolder = Path.of(\"/tmp/mlrules\");\n        try {\n            Files.createDirectories(tmpFolder);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        return archivePageNames().parallelStream()\n                                 .map(name -> HttpRequest.newBuilder(URI.create(\"https://mail.openjdk.org/pipermail/\" + list + \"/\" + name + \".txt\"))\n                                                         .GET().build())\n                                 .map(req -> {\n                                     try {\n                                         var cacheFile = tmpFolder.resolve(req.uri().getPath().replace(\"/pipermail/\", \"\").replace(\"/\", \"-\"));\n                                         if (Files.exists(cacheFile)) {\n                                             log.fine(\"Reading \" + req.uri() + \" from cache\");\n                                             return Files.readString(cacheFile);\n                                         }\n                                         System.out.println(\"Fetching \" + req.uri().toString());\n                                         var body = client.send(req, HttpResponse.BodyHandlers.ofString());\n                                         System.out.println(\"Done fetching \" + req.uri().toString());\n                                         Files.writeString(cacheFile, body.body());\n                                         return body.body();\n                                     } catch (IOException | InterruptedException e) {\n                                         throw new RuntimeException(e);\n                                     }\n                                 })\n                                 .flatMap(page -> reviewPattern.matcher(page).results().map(mr -> mr.group(1)))\n                                 .collect(Collectors.toUnmodifiableSet());\n    }\n\n    private static Map<String, Set<String>> listReviewedIssues(String... lists) {\n        var ret = new HashMap<String, Set<String>>();\n        var client = HttpClient.newBuilder()\n                               .connectTimeout(Duration.ofSeconds(30))\n                               .build();\n\n        var listIssues = Stream.of(lists).parallel()\n                               .collect(Collectors.toMap(list -> list,\n                                                         list -> listSubjects(client, list)));\n\n        for (var list : listIssues.entrySet()) {\n            for (var issue : list.getValue()) {\n                if (!ret.containsKey(issue)) {\n                    ret.put(issue, new HashSet<>());\n                }\n                ret.get(issue).add(list.getKey());\n            }\n        }\n        return ret;\n    }\n\n    private static Map<CommitMetadata, Set<String>> issueLists(List<CommitMetadata> commits, Map<String, Set<String>> listReviewedIssues) {\n        return commits.stream()\n                      .map(commit -> new AbstractMap.SimpleEntry<>(commit, CommitMessageParsers.v1.parse(commit)))\n                      .filter(entry -> !entry.getValue().issues().isEmpty())\n                      .collect(Collectors.toMap(AbstractMap.SimpleEntry::getKey,\n                                                entry -> entry.getValue().issues().stream()\n                                                              .flatMap(issue -> listReviewedIssues.getOrDefault(issue.shortId(), Set.of()).stream())\n                                                              .collect(Collectors.toSet()),\n                                                (a, b) -> a,\n                                                LinkedHashMap::new));\n    }\n\n    private static class ProgressCounter {\n        int progress;\n        int progressLen;\n    }\n\n    private static Set<String> commitChanges(ReadOnlyRepository repo, CommitMetadata commit) throws IOException {\n        var process = Process.capture(\"git\", \"diff-tree\", \"--no-commit-id\", \"--name-only\", \"-r\", commit.hash().hex())\n                             .workdir(repo.root());\n        try (var p = process.execute()) {\n            var res = p.check();\n            return new HashSet<>(res.stdout());\n        }\n    }\n\n    private static Map<CommitMetadata, Set<String>> commitPaths(ReadOnlyRepository repo, Collection<CommitMetadata> commits) {\n        var progress = new ProgressCounter();\n\n        return commits.parallelStream()\n                      .map(commit -> {\n                          try {\n                              var changedFiles = commitChanges(repo, commit);\n                              synchronized (progress) {\n                                  progress.progress++;\n                                  var progressStr = String.format(\"(%d/%d)...\", progress.progress, commits.size());\n                                  var removalStr = \"\\b\".repeat(progress.progressLen);\n                                  progress.progressLen = progressStr.length();\n                                  System.out.print(removalStr + progressStr);\n                              }\n                              var changedPaths = changedFiles.stream()\n                                                             .map(Path::of)\n                                                             .filter(Objects::nonNull)\n                                                             .map(Path::toString)\n                                                             .collect(Collectors.toSet());\n                              return new AbstractMap.SimpleEntry<>(commit, changedPaths);\n                          } catch (IOException e) {\n                              throw new UncheckedIOException(e);\n                          }\n                      })\n                      .collect(Collectors.toMap(AbstractMap.SimpleEntry::getKey,\n                                                AbstractMap.SimpleEntry::getValue));\n    }\n\n    private static Map<String, List<String>> pathLists(Map<CommitMetadata, Set<String>> commitLists, Map<CommitMetadata, Set<String>> commitPaths) {\n        var ret = new HashMap<String, List<String>>();\n\n        for (var commitPath : commitPaths.entrySet()) {\n            for (var path : commitPath.getValue()) {\n                if (!ret.containsKey(path)) {\n                    ret.put(path, new ArrayList<>());\n                }\n                var lists = commitLists.get(commitPath.getKey());\n                if (lists != null) {\n                    ret.get(path).addAll(lists);\n                }\n            }\n        }\n\n        return ret;\n    }\n\n    private static class TrieEntry {\n        String key;\n        TrieEntry parent;\n        TreeMap<String, TrieEntry> children;\n        List<String> values;\n    }\n\n    private static TrieEntry mapToTrie(Map<String, List<String>> list) {\n        var trie = new TrieEntry();\n        trie.key = \"\";\n        trie.parent = null;\n        trie.children = new TreeMap<>();\n\n        // Create a prefix tree\n        for (var entry : list.entrySet()) {\n            var curRoot = trie;\n            var pathElements = entry.getKey().split(\"/\");\n            for (var c : pathElements) {\n                if (curRoot.children.containsKey(c)) {\n                    curRoot = curRoot.children.get(c);\n                } else {\n                    var newRoot = new TrieEntry();\n                    newRoot.key = c;\n                    newRoot.parent = curRoot;\n                    newRoot.children = new TreeMap<>();\n                    curRoot.children.put(c, newRoot);\n                    curRoot = newRoot;\n                }\n            }\n            curRoot.values = entry.getValue();\n        }\n\n        return trie;\n    }\n\n    private static Map<String, List<String>> trieToMap(TrieEntry trie, String curPath) {\n        var ret = new TreeMap<String, List<String>>();\n\n        for (var child : trie.children.entrySet()) {\n            ret.putAll(trieToMap(child.getValue(), curPath + (curPath.length() > 0 ? \"/\" : \"\") + child.getKey()));\n        }\n        if (trie.values != null) {\n            ret.put(curPath, trie.values);\n        }\n\n        return ret;\n    }\n\n    private static Set<String> relevantLists(List<String> allLists) {\n        if (allLists == null || allLists.isEmpty()) {\n            return Set.of();\n        }\n\n        var listWeights = allLists.stream()\n                                  .collect(Collectors.groupingBy(Function.identity()))\n                                  .entrySet().stream()\n                                  .map(entry -> new AbstractMap.SimpleEntry<>(entry.getKey(), entry.getValue().size()))\n                                  .sorted((e1, e2) -> e2.getValue() - e1.getValue())\n                                  .collect(Collectors.toList());\n        var listWeightsMax = listWeights.stream()\n                                        .map(AbstractMap.SimpleEntry::getValue)\n                                        .max(Comparator.comparingInt(entry -> entry))\n                                        .orElseThrow();\n        var threshold = listWeightsMax / filterDivider;\n        return listWeights.stream()\n                          .filter(entry -> entry.getValue() > threshold)\n                          .map(AbstractMap.SimpleEntry::getKey)\n                          .collect(Collectors.toSet());\n    }\n\n    private static boolean listsMatch(List<String> list1, List<String> list2) {\n        if (list1 == null || list2 == null) {\n            return list1 == list2;\n        }\n        if (list1.isEmpty() || list2.isEmpty()) {\n            return list1.isEmpty() == list2.isEmpty();\n        }\n\n        var relevantLists1 = relevantLists(list1);\n        var relevantLists2 = relevantLists(list2);\n\n        return Objects.equals(relevantLists1, relevantLists2);\n    }\n\n    private static TrieEntry pruneEntry(TrieEntry root) {\n        var newChildren = new TreeMap<String, TrieEntry>();\n        if (root.children.isEmpty()) {\n            return root;\n        }\n\n        for (var child : root.children.entrySet()) {\n            newChildren.put(child.getKey(), pruneEntry(child.getValue()));\n            root.children = newChildren;\n        }\n        var firstChild = root.children.firstEntry().getValue();\n        var canBePruned = true;\n        for (var child : root.children.entrySet()) {\n            if (!child.getValue().children.isEmpty()) {\n                canBePruned = false;\n                break;\n            }\n            if (!listsMatch(child.getValue().values, firstChild.values)) {\n                canBePruned = false;\n                break;\n            }\n        }\n        if (canBePruned) {\n            if (root.values == null || listsMatch(root.values, firstChild.values)) {\n                root.children.clear();\n                root.values = firstChild.values;\n            }\n        }\n\n        return root;\n    }\n\n    static Map<String, List<String>> stripDuplicatePrefixes(Map<String, List<String>> fullList) {\n        // Create a prefix tree\n        var trie = mapToTrie(fullList);\n\n        // Prune it\n        var pruned = pruneEntry(trie);\n\n        // Restore the map from the tree\n        return trieToMap(pruned, \"\");\n    }\n\n    static Map<String, Set<String>> pathListsToListPaths(Map<String, List<String>> pathLists) {\n        var ret = new TreeMap<String, Set<String>>();\n\n        for (var entry : pathLists.entrySet()) {\n            var relevantLists = relevantLists(entry.getValue());\n            for (var list : relevantLists) {\n                if (!ret.containsKey(list)) {\n                    ret.put(list, new TreeSet<>());\n                }\n                ret.get(list).add(entry.getKey());\n            }\n        }\n\n        return ret;\n    }\n\n    static class RuleParser {\n        private final Map<String, Set<Pattern>> matchers;\n        private final Map<String, Set<String>> groups;\n\n        RuleParser(String rulesFile) throws IOException {\n            System.out.println(\"Reading rules file...\");\n            var rules = JSON.parse(Files.readString(Path.of(rulesFile)));\n\n            matchers = rules.get(\"matchers\").fields().stream()\n                            .collect(Collectors.toMap(JSONObject.Field::name,\n                                                      field -> field.value().stream()\n                                                                    .map(JSONValue::asString)\n                                                                    .map(s -> Pattern.compile(\"^\" + s, Pattern.CASE_INSENSITIVE))\n                                                                    .collect(Collectors.toSet())));\n            groups = rules.get(\"groups\").fields().stream()\n                          .collect(Collectors.toMap(JSONObject.Field::name,\n                                                    field -> field.value().stream()\n                                                                  .map(JSONValue::asString)\n                                                                  .collect(Collectors.toSet())));\n        }\n\n        Map<String, String> suggestedLists(String path) {\n            var ret = new HashMap<String, String>();\n            for (var rule : matchers.entrySet()) {\n                for (var rulePath : rule.getValue()) {\n                    var ruleMatcher = rulePath.matcher(path);\n                    if (ruleMatcher.find()) {\n                        ret.put(rule.getKey(), rulePath.toString());\n                        break;\n                    }\n                }\n            }\n\n            return ret;\n        }\n\n        TreeSet<String> groupLists(Set<String> ungrouped) {\n            var ret = new TreeSet<>(ungrouped);\n            // If the current labels matches at least two members of a group, use the group instead\n            for (var group : groups.entrySet()) {\n                var count = 0;\n                for (var groupEntry : group.getValue()) {\n                    if (ret.contains(groupEntry)) {\n                        count++;\n                        if (count == 2) {\n                            ret.add(group.getKey());\n                            ret.removeAll(group.getValue());\n                            break;\n                        }\n                    }\n                }\n            }\n            return ret;\n        }\n    }\n\n    private static void verifyInput(String rulesFile, Map<CommitMetadata, Set<String>> issueLists, Map<CommitMetadata, Set<String>> commitPaths) throws IOException {\n        var ruleParser = new RuleParser(rulesFile);\n\n        System.out.println(\"Verifying commits...\");\n        var matching = 0;\n        var mismatch = 0;\n\n        for (var issueList : issueLists.entrySet()) {\n            if (issueList.getValue().isEmpty()) {\n                // Ignore commits with unknown review list\n                continue;\n            }\n\n            var suggestedLists = new TreeSet<String>();\n            var pathMismatch = new HashMap<String, Set<String>>();\n            for (var path : commitPaths.get(issueList.getKey())) {\n                var suggestedForPath = ruleParser.suggestedLists(path);\n\n                for (var suggested : suggestedForPath.entrySet()) {\n                    if (!issueList.getValue().contains(suggested.getKey())) {\n                        if (!pathMismatch.containsKey(path)) {\n                            pathMismatch.put(path, new HashSet<>());\n                        }\n                        pathMismatch.get(path).add(suggested.getKey() + \": \" + suggested.getValue());\n                    }\n                }\n                suggestedLists.addAll(suggestedForPath.keySet());\n            }\n\n            var matchesExpected = issueList.getValue().stream()\n                                           .anyMatch(l -> listFilterPattern.matcher(l).find());\n            var matchesSuggested = suggestedLists.stream()\n                                                 .anyMatch(l -> listFilterPattern.matcher(l).find());\n            if (!matchesExpected && !matchesSuggested) {\n                continue;\n            }\n\n            // Adjust suggestions according to grouping rules\n            var suggestedListsGrouped = ruleParser.groupLists(suggestedLists);\n\n            // Also see what the expected would look like with grouping\n            var expectedGrouped = ruleParser.groupLists(issueList.getValue());\n\n            if (suggestedListsGrouped.equals(issueList.getValue())) {\n                System.out.println(\"✅ \" + suggestedListsGrouped + \" \" + issueList.getKey().hash().abbreviate() + \": \" + issueList.getKey().message().get(0));\n                matching++;\n            } else {\n                if (suggestedListsGrouped.equals(expectedGrouped)) {\n                    System.out.println(\"✅ \" + issueList.getValue() + \" -> \" + suggestedListsGrouped + \" \" + issueList.getKey().hash().abbreviate() + \": \" + issueList.getKey().message().get(0));\n                    matching++;\n                } else {\n                    var missing = issueList.getValue().stream()\n                                           .filter(value -> !suggestedLists.contains(value))\n                                           .collect(Collectors.toSet());\n                    var extra = suggestedLists.stream()\n                                              .filter(value -> !issueList.getValue().contains(value))\n                                              .collect(Collectors.toSet());\n                    System.out.println(\"❌ \" + issueList.getValue() + \" \" + issueList.getKey().hash().abbreviate() + \": \" + issueList.getKey().message().get(0));\n                    if (suggestedListsGrouped.equals(suggestedLists)) {\n                        System.out.println(\"    Suggested lists: \" + suggestedListsGrouped);\n                    } else {\n                        System.out.println(\"    Suggested lists: \" + suggestedListsGrouped + \" (ungrouped: \" + suggestedLists + \")\");\n                    }\n\n                    //System.out.println(\"Actual lists   : \" + issueList.getValue());\n                    //commitPaths.get(issueList.getKey()).forEach(s -> System.out.println(\"  \" + s));\n                    if (!extra.isEmpty()) {\n                        System.out.println(\"    Rules matching unmentioned lists \" + extra + \":\");\n                        for (var path : pathMismatch.entrySet()) {\n                            System.out.println(\"      \" + path.getKey() + \" - \" + path.getValue());\n                        }\n                    }\n                    if (!missing.isEmpty()) {\n                        var unmatched = commitPaths.get(issueList.getKey()).stream()\n                                                   .filter(entry -> !pathMismatch.containsKey(entry))\n                                                   .collect(Collectors.toList());\n                        if (!unmatched.isEmpty()) {\n                            System.out.println(\"    Files not matching any rule in \" + missing + \":\");\n                            unmatched.forEach(s -> System.out.println(\"      \" + s));\n                        }\n                    }\n                    mismatch++;\n                }\n            }\n        }\n\n        System.out.println(\"Matches: \" + matching + \" - mismatches: \" + mismatch);\n    }\n\n    public static void main(String[] args) throws IOException {\n        var parser = new ArgumentParser(\"git skara debug mlrules\", flags, inputs);\n        var arguments = parser.parse(args);\n\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            Logging.setup(level);\n        }\n        if (arguments.contains(\"days\")) {\n            daysOfHistory = arguments.get(\"days\").asInt();\n        }\n        if (arguments.contains(\"filter\")) {\n            filterDivider = arguments.get(\"filter\").asInt();\n        }\n        if (arguments.contains(\"relaxed\")) {\n            reviewPattern = rfrSubjectOrIssue;\n        }\n        if (arguments.contains(\"lists\")) {\n            listFilterPattern = Pattern.compile(arguments.get(\"lists\").asString());\n        }\n\n        var parsedLists = List.of(\"2d-dev\",\n                                  \"awt-dev\",\n                                  \"build-dev\",\n                                  \"compiler-dev\",\n                                  \"core-libs-dev\",\n                                  \"hotspot-compiler-dev\",\n                                  \"hotspot-gc-dev\",\n                                  \"hotspot-jfr-dev\",\n                                  \"hotspot-runtime-dev\",\n                                  \"i18n-dev\",\n                                  \"javadoc-dev\",\n                                  \"net-dev\",\n                                  \"nio-dev\",\n                                  \"security-dev\",\n                                  \"serviceability-dev\",\n                                  \"sound-dev\",\n                                  \"swing-dev\");\n        if (arguments.contains(\"verify\")) {\n            parsedLists = Stream.concat(parsedLists.stream(), List.of(\"hotspot-dev\", \"jdk-dev\").stream())\n                                .collect(Collectors.toList());\n        }\n\n        var repoPath = Path.of(arguments.at(0).asString()).toRealPath();\n        if (repoPath.toFile().isFile()) {\n            repoPath = repoPath.getParent();\n        }\n        var repo = ReadOnlyRepository.get(repoPath).orElseThrow();\n        var repoRoot = repo.root();\n\n        if (arguments.inputs().size() == 1 && repoPath.equals(repoRoot)) {\n            System.out.println(\"Fetching commits metadata...\");\n            var cutoff = ZonedDateTime.now().minus(Duration.ofDays(daysOfHistory));\n            var commits = repo.commitMetadata().stream()\n                              .filter(commit -> commit.committed().isAfter(cutoff))\n                              .collect(Collectors.toList());\n\n            System.out.println(\"Done fetching commits metadata: \" + commits.size() + \" commits remaining after date filtering\");\n\n            var listReviews = listReviewedIssues(parsedLists.toArray(new String[0]));\n            System.out.println(\"Done fetching mailing list archive pages\");\n\n            var issueLists = issueLists(commits, listReviews);\n            var noReviewCount = issueLists.entrySet().stream()\n                                          .filter(entry -> entry.getValue().isEmpty())\n                                          .count();\n            System.out.println(\"Done mapping commit issues to lists: \" + noReviewCount + \" commits have no matching review\");\n\n            for (var issue : issueLists.entrySet()) {\n                if (!issue.getValue().isEmpty()) {\n                    log.fine(issue.getKey().hash().abbreviate() + \": \" + issue.getKey().message().get(0) + \": \" + issue.getValue());\n                }\n            }\n\n            System.out.print(\"Fetching commit changes: \");\n            var commitPaths = commitPaths(repo, issueLists.keySet());\n            for (var commitPath : commitPaths.entrySet()) {\n                log.fine(commitPath.getKey().hash().abbreviate() + \": \" + commitPath.getValue());\n            }\n            System.out.println(\" done\");\n\n            System.out.println(\"Fetching list of existing files...\");\n            var currentPaths = repo.files(repo.head(), List.of()).stream()\n                                   .map(FileEntry::path)\n                                   .filter(Objects::nonNull)\n                                   .map(Path::toString)\n                                   .collect(Collectors.toSet());\n\n\n            var existingCommitPaths = commitPaths.entrySet().stream()\n                                                 .collect(Collectors.toMap(Map.Entry::getKey,\n                                                                           entry -> entry.getValue().stream()\n                                                                                         .filter(currentPaths::contains)\n                                                                                         .collect(Collectors.toSet())));\n\n            if (arguments.contains(\"verify\")) {\n                verifyInput(arguments.get(\"verify\").asString(), issueLists, existingCommitPaths);\n                return;\n            }\n\n            var pathLists = pathLists(issueLists, existingCommitPaths);\n            var unknownPaths = currentPaths.stream()\n                                           .filter(p -> !pathLists.containsKey(p))\n                                           .collect(Collectors.toCollection(TreeSet::new));\n\n            var uniquePathLists = stripDuplicatePrefixes(pathLists);\n            for (var pathList : uniquePathLists.entrySet()) {\n                var relevantLists = relevantLists(pathList.getValue());\n                log.fine(pathList.getKey() + \": \" + relevantLists);\n            }\n\n            var listPaths = pathListsToListPaths(uniquePathLists);\n            listPaths.put(\"unknown\", unknownPaths);\n\n            var finalResult = \"{\\n\" + listPaths.entrySet().stream()\n                                               .map(entry -> \"    \\\"\" + entry.getKey() + \"\\\": [\\n\" +\n                                                       entry.getValue().stream()\n                                                            .map(path -> \"        \\\"\" + path + \"\\\"\")\n                                                            .collect(Collectors.joining(\",\\n\")) +\n                                                       \"\\n    ]\")\n                                               .collect(Collectors.joining(\",\\n\")) +\n                    \"\\n}\";\n            if (arguments.contains(\"output\")) {\n                System.out.println(\"Writing final output to \" + arguments.get(\"output\").asString());\n                Files.writeString(Path.of(arguments.get(\"output\").asString()), finalResult);\n            } else {\n                System.out.println(finalResult);\n            }\n        } else if (arguments.inputs().size() >= 1 && arguments.contains(\"verify\")) {\n            var requestedFiles = new HashSet<String>();\n            for (var input : arguments.inputs()) {\n                var path = Path.of(input.asString());\n                try {\n                    // Normalize, if possible\n                    path = path.toRealPath();\n                } catch (IOException ioe) {\n                    // If the file does not exist, use the name as-is\n                }\n                if (path.toFile().isFile()) {\n                    requestedFiles.add(repoRoot.relativize(path).toString());\n                } else {\n                    try (var paths = Files.walk(path)) {\n                        paths.filter(p -> p.toFile().isFile())\n                             .map(p -> repoRoot.relativize(p).toString())\n                             .forEach(requestedFiles::add);\n                    }\n                }\n            }\n\n            var ruleParser = new RuleParser(arguments.get(\"verify\").asString());\n            var pathLists = requestedFiles.stream()\n                                          .collect(Collectors.toMap(Function.identity(),\n                                                                    p -> (List<String>)new ArrayList<>(ruleParser.suggestedLists(p).keySet())));\n            var uniquePathLists = stripDuplicatePrefixes(pathLists);\n            var suggestedLists = new TreeSet<String>();\n            for (var uniquePath : uniquePathLists.entrySet()) {\n                System.out.println(uniquePath.getKey() + \": \" + uniquePath.getValue());\n                suggestedLists.addAll(uniquePath.getValue());\n            }\n            System.out.println();\n            System.out.println(\"Combined list suggestion: \" + suggestedLists);\n            System.out.println(\"Final list suggestion is: \" + ruleParser.groupLists(suggestedLists));\n        } else {\n            System.out.println(\"To generate a rules list from parsing review archives:\");\n            System.out.println(\"  git skara mlrules <repository root> [--filter X] [--days D] [--output FILE]\");\n            System.out.println();\n            System.out.println(\"To verify a rules list against historical commits and reviews:\");\n            System.out.println(\"  git skara mlrules <repository root> [--verify CONFIG-FILE] [--days D]\");\n            System.out.println();\n            System.out.println(\"To verify a config file against a given list of files/directories in a repository:\");\n            System.out.println(\"  git skara mlrules --verify CONFIG-FILE <file1/dir1> [<file2/dir2> <file3/dir3>...]\");\n            System.out.println();\n            System.out.println(\"For the full list of options:\");\n            System.out.println(\"  git skara mlrules --help\");\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/debug/GitOpenJDKImport.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.debug;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.cli.Logging;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.convert.*;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.function.Supplier;\nimport java.util.logging.Level;\n\nimport static java.util.stream.Collectors.toList;\n\npublic class GitOpenJDKImport {\n    static final List<Flag> flags = List.of(\n            Option.shortcut(\"\")\n                  .fullname(\"replacements\")\n                  .describe(\"FILE\")\n                  .helptext(\"JSON file with replacements\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"corrections\")\n                  .describe(\"FILE\")\n                  .helptext(\"JSON file with corrections\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"authors\")\n                  .describe(\"FILE\")\n                  .helptext(\"Comma separated list of JSON files with author info\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"contributors\")\n                  .describe(\"FILE\")\n                  .helptext(\"JSON file with contributor info\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"lowercase\")\n                  .describe(\"FILE\")\n                  .helptext(\"JSON file with commits allowed to start with lowercase\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"punctuated\")\n                  .describe(\"FILE\")\n                  .helptext(\"JSON file with commits allowed to end with '.'\")\n                  .optional(),\n            Option.shortcut(\"\")\n                  .fullname(\"sponsors\")\n                  .describe(\"FILE\")\n                  .helptext(\"JSON file with sponsor info\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"verbose\")\n                  .helptext(\"Turn on verbose output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"debug\")\n                  .helptext(\"Turn on debugging output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"version\")\n                  .helptext(\"Print the version of this tool\")\n                  .optional());\n\n    static final List<Input> inputs = List.of(\n            Input.position(0)\n                 .describe(\"REPO\")\n                 .singular()\n                 .required());\n\n    private static void die(Exception e) {\n        System.err.println(e.getMessage());\n        System.exit(1);\n    }\n\n    private static Supplier<NoSuchElementException> error(String fmt, Object... args) {\n        return () -> new NoSuchElementException(String.format(fmt, args));\n    }\n\n    public static void main(String[] args) {\n        var parser = new ArgumentParser(\"git skara debug hg-import\", flags, inputs);\n        var arguments = parser.parse(args);\n\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git skara debug hg-import version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        try {\n            var cwd = Path.of(\"\").toAbsolutePath();\n            var gitRepo = Repository.get(cwd)\n                                    .orElseThrow(error(\"%s is not a git repository\", cwd));\n\n            var hgDir = arguments.at(0).via(Path::of);\n            var hgRepo = Repository.get(hgDir)\n                                   .orElseThrow(error(\"%s is not a hg repository\", hgDir));\n\n            var replacements = new HashMap<Hash, List<String>>();\n            if (arguments.contains(\"replacements\")) {\n                var f = arguments.get(\"replacements\").via(Path::of);\n                var json = JSON.parse(Files.readString(f));\n                for (var field : json.fields()) {\n                    var hash = new Hash(field.name());\n                    var message = field.value().stream().map(e -> e.asString()).collect(toList());\n                    replacements.put(hash, message);\n                }\n            }\n\n            var corrections = new HashMap<Hash, Map<String, String>>();\n            if (arguments.contains(\"corrections\")) {\n                var f = arguments.get(\"corrections\").via(Path::of);\n                var json = JSON.parse(Files.readString(f));\n                for (var field : json.fields()) {\n                    var hash = new Hash(field.name());\n                    corrections.put(hash, new HashMap<>());\n\n                    for (var entry : field.value().fields()) {\n                        var from = entry.name();\n                        var to = entry.value().asString();\n                        corrections.get(hash).put(from, to);\n                    }\n                }\n            }\n\n            var lowercase = new HashSet<Hash>();\n            if (arguments.contains(\"lowercase\")) {\n                var f = arguments.get(\"lowercase\").via(Path::of);\n                var json = JSON.parse(Files.readString(f));\n                for (var hash : json.get(\"commits\").asArray()) {\n                    lowercase.add(new Hash(hash.asString()));\n                }\n            }\n\n            var punctuated = new HashSet<Hash>();\n            if (arguments.contains(\"punctuated\")) {\n                var f = arguments.get(\"punctuated\").via(Path::of);\n                var json = JSON.parse(Files.readString(f));\n                for (var hash : json.get(\"commits\").asArray()) {\n                    punctuated.add(new Hash(hash.asString()));\n                }\n            }\n\n            var authors = new HashMap<String, String>();\n            if (arguments.contains(\"authors\")) {\n                var files = Arrays.stream(arguments.get(\"authors\").asString().split(\",\"))\n                                  .map(Path::of)\n                                  .collect(toList());\n                for (var f : files) {\n                    var json = JSON.parse(Files.readString(f));\n                    for (var field : json.fields()) {\n                        authors.put(field.name(), field.value().asString());\n                    }\n                }\n            }\n\n            var contributors = new HashMap<String, String>();\n            if (arguments.contains(\"contributors\")) {\n                var f = arguments.get(\"contributors\").via(Path::of);\n                var json = JSON.parse(Files.readString(f));\n                for (var field : json.fields()) {\n                    contributors.put(field.name(), field.value().asString());\n                }\n            }\n\n            var sponsors = new HashMap<String, List<String>>();\n            if (arguments.contains(\"sponsors\")) {\n                var f = arguments.get(\"sponsors\").via(Path::of);\n                var json = JSON.parse(Files.readString(f));\n                for (var field : json.fields()) {\n                    var name = field.name();\n                    var emails = field.value().stream().map(e -> e.asString()).collect(toList());\n                    sponsors.put(name, emails);\n                }\n            }\n\n            if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n                var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n                Logging.setup(level);\n            }\n\n            var converter = new HgToGitConverter(replacements, corrections, lowercase, punctuated, authors, contributors, sponsors);\n            var hgCommits = gitRepo.root().resolve(\".hgcommits\");\n            List<Mark> marks;\n            if (Files.exists(hgCommits)) {\n                var lines = Files.readAllLines(hgCommits);\n                marks = new ArrayList<>();\n                for (int i = 0; i < lines.size(); ++i) {\n                    var markHashes = lines.get(i).split(\" \");\n                    var mark = new Mark(i + 1, new Hash(markHashes[0]), new Hash(markHashes[1]));\n                    marks.add(mark);\n                }\n                marks = converter.pull(hgRepo, URI.create(hgRepo.pullPath(\"default\")), gitRepo, marks);\n            } else {\n                marks = converter.convert(hgRepo, gitRepo);\n            }\n\n            try (var writer = Files.newBufferedWriter(hgCommits)) {\n                for (var mark : marks) {\n                    writer.write(mark.hg().hex());\n                    writer.write(\" \");\n                    writer.write(mark.git().hex());\n                    writer.newLine();\n                }\n            }\n        } catch (NoSuchElementException | IOException e) {\n            die(e);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/debug/GitVerifyImport.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.debug;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.stream.*;\n\npublic class GitVerifyImport {\n    static final List<Flag> flags = List.of(\n            Switch.shortcut(\"\")\n                  .fullname(\"verbose\")\n                  .helptext(\"Turn on verbose output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"version\")\n                  .helptext(\"Print the version of this tool\")\n                  .optional());\n\n    static final List<Input> inputs = List.of(\n            Input.position(0)\n                 .describe(\"hg repository\")\n                 .singular()\n                 .required());\n\n    private static boolean isVerbose;\n\n    private static <T> void diff(Set<T> hg, Set<T> git, String description) throws IOException {\n        System.err.println(\"The following \" + description + \" are in the git repostiory but not in the hg repository\");\n        var diff = new TreeSet<>(git);\n        diff.removeAll(hg);\n        for (var e : diff) {\n            System.err.println(\"      \" + e.toString());\n        }\n\n        System.err.println(\"The following \" + description + \" are in the hg repository but not in the git repository\");\n        diff = new TreeSet<>(hg);\n        diff.removeAll(git);\n        for (var e : diff) {\n            System.err.println(\"      \" + e.toString());\n        }\n    }\n\n    private static Set<String> verifyBranches(Repository hg, Repository git) throws IOException {\n        var hgBranches = hg.branches()\n                           .stream()\n                           .map(Branch::name)\n                           .collect(Collectors.toSet());\n        var gitBranches = git.branches()\n                             .stream()\n                             .map(Branch::name)\n                             .map(b -> b.equals(Branch.defaultFor(VCS.GIT).name()) ? Branch.defaultFor(VCS.HG).name() : b)\n                             .collect(Collectors.toSet());\n        if (!hgBranches.equals(gitBranches)) {\n            if (isVerbose) {\n                diff(hgBranches, gitBranches, \"branches\");\n            }\n            System.exit(1);\n        }\n\n        return hgBranches;\n    }\n\n    private static Set<String> verifyTags(Repository hg, Repository git) throws IOException {\n        var hgTags = hg.tags()\n                       .stream()\n                       .map(Tag::name)\n                       .filter(t -> !t.equals(\"tip\"))\n                       .collect(Collectors.toSet());\n        var gitTags = git.tags()\n                         .stream()\n                         .map(Tag::name)\n                         .collect(Collectors.toSet());\n        if (!hgTags.equals(gitTags)) {\n            if (isVerbose) {\n                diff(hgTags, gitTags, \"tags\");\n            }\n            System.exit(1);\n        }\n\n        return hgTags;\n    }\n\n    private static int compare(Path p1, Path p2) throws IOException {\n        var length = 1024;\n        var buffer1 = new byte[length];\n        var buffer2 = new byte[length];\n\n        var totalRead = 0;\n        var size = Files.size(p1);\n\n        try (var is1 = Files.newInputStream(p1); var is2 = Files.newInputStream(p2)) {\n            while (totalRead != size) {\n                var read1 = is1.readNBytes(buffer1, 0, length);\n                var read2 = is2.readNBytes(buffer2, 0, length);\n\n                if (read1 != read2) {\n                    throw new RuntimeException(\"impossible: read1: \" + read1 + \", read2: \" + read2);\n                }\n\n                var index = Arrays.mismatch(buffer1, 0, read1, buffer2, 0, read2);\n                if (index != -1) {\n                    return totalRead + index;\n                }\n\n                totalRead += read1;\n            }\n        }\n\n        return -1;\n    }\n\n    private static void verifyFiles(Repository hg, String hgRef, Repository git, String gitRef) throws IOException {\n        hg.checkout(hg.resolve(hgRef).get(), false);\n        git.checkout(git.resolve(gitRef).get(), false);\n\n        var hgRoot = hg.root();\n        var hgFiles = new HashSet<Path>();\n        try (var paths = Files.walk(hgRoot)) {\n            paths.filter(p -> !Files.isDirectory(p))\n                 .map(hgRoot::relativize)\n                 .filter(p -> !p.startsWith(\".hg\"))\n                 .forEach(f -> hgFiles.add(f));\n        }\n\n        var gitRoot = git.root();\n        var gitFiles = new HashSet<Path>();\n        try (var paths = Files.walk(gitRoot)) {\n            paths.filter(p -> !Files.isDirectory(p))\n                 .map(gitRoot::relativize)\n                 .filter(p -> !p.startsWith(\".git\"))\n                 .forEach(f -> gitFiles.add(f));\n        }\n\n        if (!hgFiles.equals(gitFiles)) {\n            if (isVerbose) {\n                diff(hgFiles, gitFiles, \"files\");\n            }\n            System.exit(1);\n        }\n\n        for (var file : hgFiles) {\n            var hgFile = hgRoot.resolve(file);\n            var gitFile = gitRoot.resolve(file);\n            if (Files.size(hgFile) != Files.size(gitFile)) {\n                System.err.println(\"error: file \" + file + \" have different size\");\n            }\n\n            try {\n                var p1 = Files.getPosixFilePermissions(hgFile);\n                var p2 = Files.getPosixFilePermissions(gitFile);\n                if (!p1.equals(p2)) {\n                    System.err.println(\"error: the file \" + file + \" have different permissions\");\n                }\n            } catch (UnsupportedOperationException e) {\n                System.err.println(\"warning: this file system does not suppport POSIX permissions\");\n            }\n        }\n\n        for (var file : hgFiles) {\n            var pos = compare(hgRoot.resolve(file), gitRoot.resolve(file));\n            if (pos != -1) {\n                System.err.println(\"error: file \" + file.toString() + \" differ at byte \" + pos);\n            }\n        }\n    }\n\n    private static Repository createTempRepository(ReadOnlyRepository origin) throws IOException {\n        return origin.copyTo(Files.createTempDirectory(\"verify-import\"));\n    }\n\n    public static void main(String[] args) throws IOException {\n\n\n        var parser = new ArgumentParser(\"git verify-import\", flags, inputs);\n        var arguments = parser.parse(args);\n\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-verify-import version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        isVerbose = arguments.contains(\"verbose\");\n\n        var hgRepoPath = arguments.at(0).via(Path::of);\n        var originalHgRepo = ReadOnlyRepository.get(hgRepoPath);\n        if (!originalHgRepo.isPresent()) {\n            System.err.println(\"No hg repository found at \" + hgRepoPath);\n            System.exit(1);\n        }\n        var hg = createTempRepository(originalHgRepo.get());\n\n        var gitRepoPath = Path.of(System.getProperty(\"user.dir\"));\n        var originalGitRepo = ReadOnlyRepository.get(gitRepoPath);\n        if (!originalGitRepo.isPresent()) {\n            System.err.println(\"No git repository found at \" + gitRepoPath);\n            System.exit(1);\n        }\n        var git = createTempRepository(originalGitRepo.get());\n\n        var branches = verifyBranches(hg, git);\n        var tags = verifyTags(hg, git);\n\n        for (var branch : branches) {\n            verifyFiles(hg, branch, git, branch.equals(Branch.defaultFor(VCS.HG).name()) ? Branch.defaultFor(VCS.GIT).name() : branch);\n        }\n\n        for (var tag : tags) {\n            verifyFiles(hg, tag, git, tag);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/debug/HgOpenJDKImport.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.debug;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.cli.Logging;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.convert.GitToHgConverter;\nimport org.openjdk.skara.vcs.openjdk.convert.Mark;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.function.Supplier;\nimport java.util.logging.Level;\n\npublic class HgOpenJDKImport {\n    static final List<Flag> flags = List.of(\n            Switch.shortcut(\"\")\n                  .fullname(\"verbose\")\n                  .helptext(\"Turn on verbose output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"debug\")\n                  .helptext(\"Turn on debugging output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"version\")\n                  .helptext(\"Print the version of this tool\")\n                  .optional());\n\n    static final List<Input> inputs = List.of(\n            Input.position(0)\n                 .describe(\"REPO\")\n                 .singular()\n                 .required());\n\n    static class ErrorException extends RuntimeException {\n        ErrorException(String s) {\n            super(s);\n        }\n    }\n\n    private static Supplier<ErrorException> error(String fmt, Object... args) {\n        return () -> new ErrorException(String.format(fmt, args));\n    }\n\n    public static void main(String[] args) throws IOException {\n        var parser = new ArgumentParser(\"git skara debug git-import\", flags, inputs);\n        var arguments = parser.parse(args);\n\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git skara debug git-import version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            Logging.setup(level);\n        }\n\n        try {\n            var cwd = Path.of(\"\").toAbsolutePath();\n            var hgRepo = Repository.get(cwd)\n                                   .orElseThrow(error(\"%s is not a hg repository\", cwd));\n\n            var gitDir = arguments.at(0).via(Path::of);\n            var gitRepo = ReadOnlyRepository.get(gitDir)\n                                            .orElseThrow(error(\"%s is not a git repository\", gitDir));\n\n            var converter = new GitToHgConverter(Branch.defaultFor(VCS.GIT));\n            try {\n                var shamap = hgRepo.root().resolve(\".hg\").resolve(\"shamap\");\n                var marks = new ArrayList<Mark>();\n                if (Files.exists(shamap)) {\n                    var lines = Files.readAllLines(shamap);\n                    for (var i = 0; i < lines.size(); i++) {\n                        var line = lines.get(i);\n                        var parts = line.split(\" \");\n                        var key = i + 1;\n                        if (parts.length == 2) {\n                            marks.add(new Mark(key, new Hash(parts[1]), new Hash(parts[0])));\n                        } else if (parts.length == 3) {\n                            marks.add(new Mark(key, new Hash(parts[1]), new Hash(parts[0]), new Hash(parts[2])));\n                        } else {\n                            throw new IllegalStateException(\"Unexpected line at row \" + i + \": \" + line);\n                        }\n                    }\n                }\n                converter.convert(gitRepo, hgRepo, marks);\n            } finally {\n                var marks = converter.marks();\n                if (!marks.isEmpty()) {\n                    var shamap = hgRepo.root().resolve(\".hg\").resolve(\"shamap\");\n                    try (var writer = Files.newBufferedWriter(shamap)) {\n                        for (var mark : marks) {\n                            writer.write(mark.git().hex());\n                            writer.write(\" \");\n                            writer.write(mark.hg().hex());\n                            writer.newLine();\n                        }\n                    }\n                }\n            }\n        } catch (ErrorException e) {\n            System.err.println(e.getMessage());\n            System.exit(1);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/debug/IssueRedecorate.java",
    "content": "/*\n * Copyright (c) 2021, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.debug;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.jbs.*;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.util.*;\n\npublic class IssueRedecorate {\n    static final List<Flag> flags = List.of(\n            Switch.shortcut(\"u\")\n                  .fullname(\"url\")\n                  .helptext(\"Alternative JBS URL (defaults to https://bugs.openjdk.org)\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"version\")\n                  .helptext(\"Print the version of this tool\")\n                  .optional());\n\n    static final List<Input> inputs = List.of(\n            Input.position(0)\n                 .describe(\"issue ID\")\n                 .singular()\n                 .required()\n            );\n\n    public static void main(String[] args) throws IOException {\n        var parser = new ArgumentParser(\"git issue-redecorate\", flags, inputs);\n        var arguments = parser.parse(args);\n\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-issue-redecorate version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n\n        IssueTracker issueTracker = null;\n        var issueTrackerURI = URI.create(arguments.get(\"url\").orString(\"https://bugs.openjdk.org\"));\n        var issueTrackerFactories = IssueTrackerFactory.getIssueTrackerFactories();\n        for (var issueTrackerFactory : issueTrackerFactories) {\n            var tracker = issueTrackerFactory.create(issueTrackerURI, null, null);\n            if (tracker.isValid()) {\n                issueTracker = tracker;\n            }\n        }\n        if (issueTracker == null) {\n            System.out.println(\"Failed to create an issue tracker instance for \" + issueTrackerURI);\n            System.exit(1);\n        }\n\n        var issueProject = issueTracker.project(\"JDK\");\n        IssueTrackerIssue issue = issueProject.issue(arguments.at(0).asString()).orElseThrow();\n\n        var mainIssue = Backports.findMainIssue(issue);\n        if (mainIssue.isEmpty()) {\n            System.out.println(\"No corresponding main issue found\");\n            System.exit(0);\n        }\n        System.out.println(\"Looking at \" + arguments.at(0).asString() + \" - main issue is \" + mainIssue.get().id());\n\n        var related = Backports.findBackports(mainIssue.get(), true);\n        var allIssues = new ArrayList<IssueTrackerIssue>();\n        allIssues.add(mainIssue.get());\n        allIssues.addAll(related);\n\n        var needsLabel = new HashSet<>(Backports.releaseStreamDuplicates(allIssues));\n        for (var i : allIssues) {\n            var version = Backports.mainFixVersion(i);\n            var versionString = version.map(JdkVersion::raw).orElse(\"no fix version\");\n            if (needsLabel.contains(i)) {\n                if (i.labelNames().contains(\"hgupdate-sync\")) {\n                    System.out.println(\"✔️ \" + i.id() + \" (\" + versionString + \") - already labeled\");\n                } else {\n                    System.out.println(\"⏳ \" + i.id() + \" (\" + versionString + \") - needs to be labeled\");\n                }\n            } else {\n                if (i.labelNames().contains(\"hgupdate-sync\")) {\n                    System.out.println(\"❌ \" + i.id() + \" (\" + versionString + \") - labeled incorrectly\");\n                } else {\n                    System.out.println(\"✔️ \" + i.id() + \" (\" + versionString + \") - not labeled\");\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/debug/SkaraDebugHelp.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.debug;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.cli.Logging;\nimport org.openjdk.skara.version.Version;\n\nimport java.util.*;\nimport java.util.logging.Level;\nimport java.util.stream.Collectors;\n\npublic class SkaraDebugHelp {\n    private static final class Pair<T1, T2> {\n        T1 e1;\n        T2 e2;\n\n        Pair(T1 e1, T2 e2) {\n            this.e1 = e1;\n            this.e2 = e2;\n        }\n\n        static <T3, T4> Pair<T3, T4> of(T3 e1, T4 e2) {\n            return new Pair<>(e1, e2);\n        }\n\n        T1 first() {\n            return e1;\n        }\n\n        T2 second() {\n            return e2;\n        }\n    }\n\n    private static final Map<String, Pair<List<Input>, List<Flag>>> commands = new HashMap<>();\n\n    static {\n        commands.put(\"import-git\", Pair.of(GitOpenJDKImport.inputs, GitOpenJDKImport.flags));\n        commands.put(\"import-hg\", Pair.of(HgOpenJDKImport.inputs, HgOpenJDKImport.flags));\n        commands.put(\"verify-import\", Pair.of(GitVerifyImport.inputs, GitVerifyImport.flags));\n        commands.put(\"mlrules\", Pair.of(GitMlRules.inputs, GitMlRules.flags));\n    }\n\n    private static String describe(List<Input> inputs) {\n        return inputs.stream().map(Input::toString).collect(Collectors.joining(\" \"));\n    }\n\n    private static<T> TreeSet<T> sorted(Set<T> s) {\n        return new TreeSet<>(s);\n    }\n\n    private static void showHelpFor(String command, int indentation) {\n        var inputs = commands.get(command).first();\n        var flags = commands.get(command).second();\n\n        System.out.println(\" \".repeat(indentation) + \"Usage: git skara debug \" + command + \" \" + describe(inputs));\n        System.out.println(\" \".repeat(indentation) + \"Flags:\");\n        ArgumentParser.showFlags(System.out, flags, \" \".repeat(indentation + 2));\n    }\n\n    public static void main(String[] args) {\n        var flags = List.of(\n                Switch.shortcut(\"h\")\n                      .fullname(\"help\")\n                      .helptext(\"Show help\")\n                      .optional(),\n                Switch.shortcut(\"\")\n                      .fullname(\"verbose\")\n                      .helptext(\"Turn on verbose output\")\n                      .optional(),\n                Switch.shortcut(\"\")\n                      .fullname(\"debug\")\n                      .helptext(\"Turn on debugging output\")\n                      .optional(),\n                Switch.shortcut(\"\")\n                      .fullname(\"version\")\n                      .helptext(\"Print the version of this tool\")\n                      .optional()\n        );\n\n        var inputs = List.of(\n                Input.position(0)\n                     .describe(\"COMMAND\")\n                     .singular()\n                     .optional()\n        );\n\n        var parser = new ArgumentParser(\"git skara debug\", flags, inputs);\n        var arguments = parser.parse(args);\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git skara debug version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            Logging.setup(level);\n        }\n\n        if (arguments.at(0).isPresent()) {\n            var command = arguments.at(0).asString();\n            if (commands.keySet().contains(command)) {\n                showHelpFor(command, 0);\n                System.exit(0);\n            } else {\n                System.err.println(\"error: unknown sub-command: \" + command);\n                System.err.println(\"\");\n                System.err.println(\"Available sub-commands are:\");\n                for (var subcommand : sorted(commands.keySet())) {\n                    System.err.println(\"- \" + subcommand);\n                }\n                System.exit(1);\n            }\n        }\n\n        System.out.println(\"git skara debug is used for interacting with Skara debug commands.\");\n        System.out.println(\"The following commands are available:\");\n        for (var command : sorted(commands.keySet())) {\n            System.out.println(\"- \" + command);\n            showHelpFor(command, 2);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrApply.java",
    "content": "/*\n * Copyright (c) 2019, 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.util.List;\n\npublic class GitPrApply {\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"u\")\n              .fullname(\"username\")\n              .describe(\"NAME\")\n              .helptext(\"Username on host\")\n              .optional(),\n        Option.shortcut(\"r\")\n              .fullname(\"remote\")\n              .describe(\"NAME\")\n              .helptext(\"Name of remote, defaults to 'origin'\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"no-token\")\n              .helptext(\"Do not use a personal access token (PAT)\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException {\n        var parser = new ArgumentParser(\"git-pr apply\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n\n        var fetchHead = repo.fetch(pr.repository().webUrl(), pr.fetchRef()).orElseThrow();\n        var patch = diff(pr.targetRef(), fetchHead);\n        apply(patch);\n        Files.deleteIfExists(patch);\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrCC.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.forge.PullRequest;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\npublic class GitPrCC {\n    static final List<Flag> flags = List.of(\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ARG\")\n             .trailing()\n             .required()\n    );\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var parser = new ArgumentParser(\"git-pr cc\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var prId = arguments.at(0).asString().matches(\"[0-9]+\") ?\n            arguments.at(0).asString() : null;\n        var pr = getPullRequest(uri, repo, host, prId);\n\n        var labelsStartIndex = prId == null ? 0 : 1;\n        var labels = arguments.inputs()\n                              .stream()\n                              .skip(labelsStartIndex)\n                              .map(Argument::asString)\n                              .collect(Collectors.joining(\" \"));\n        var comment = pr.addComment(\"/cc \" + labels);\n        showReply(awaitReplyTo(pr, comment));\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrCSR.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.forge.PullRequest;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.util.*;\n\npublic class GitPrCSR {\n    static final List<Flag> flags = List.of(\n        Switch.shortcut(\"\")\n              .fullname(\"needed\")\n              .helptext(\"This pull request needs an approved CSR before integration\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"unneeded\")\n              .helptext(\"This pull request does not need an approved CSR before integration\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var parser = new ArgumentParser(\"git-pr csr\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n\n        if (arguments.contains(\"needed\")) {\n            var comment = pr.addComment(\"/csr needed\");\n            showReply(awaitReplyTo(pr, comment));\n        } else if (arguments.contains(\"unneeded\")) {\n            var comment = pr.addComment(\"/csr unneeded\");\n            showReply(awaitReplyTo(pr, comment));\n        } else {\n            System.err.println(\"error: must use either --needed or --unneeded\");\n            System.exit(1);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrCheckout.java",
    "content": "/*\n * Copyright (c) 2019, 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.util.List;\n\npublic class GitPrCheckout {\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"u\")\n              .fullname(\"username\")\n              .describe(\"NAME\")\n              .helptext(\"Username on host\")\n              .optional(),\n        Option.shortcut(\"r\")\n              .fullname(\"remote\")\n              .describe(\"NAME\")\n              .helptext(\"Name of remote, defaults to 'origin'\")\n              .optional(),\n        Option.shortcut(\"b\")\n              .fullname(\"branch\")\n              .describe(\"NAME\")\n              .helptext(\"Create a new branched named NAME\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"no-token\")\n              .helptext(\"Do not use a personal access token (PAT)\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException {\n        var parser = new ArgumentParser(\"git-pr checkout\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n\n        var fetchHead = repo.fetch(pr.repository().webUrl(), pr.fetchRef()).orElseThrow();\n        var branchName = getOption(\"branch\", \"checkout\", arguments);\n        if (branchName != null) {\n            var branch = repo.branch(fetchHead, branchName);\n            repo.checkout(branch, false);\n        } else {\n            repo.checkout(fetchHead, false);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrClose.java",
    "content": "/*\n * Copyright (c) 2019, 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.forge.PullRequest;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.util.List;\n\npublic class GitPrClose {\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"u\")\n              .fullname(\"username\")\n              .describe(\"NAME\")\n              .helptext(\"Username on host\")\n              .optional(),\n        Option.shortcut(\"r\")\n              .fullname(\"remote\")\n              .describe(\"NAME\")\n              .helptext(\"Name of remote, defaults to 'origin'\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var parser = new ArgumentParser(\"git-pr close\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n\n        pr.setState(PullRequest.State.CLOSED);\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrContributor.java",
    "content": "/*\n * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.cli.ForgeUtils;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.util.*;\n\npublic class GitPrContributor {\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"\")\n              .fullname(\"add\")\n              .describe(\"USERNAME\")\n              .helptext(\"Consider pull request contributed to by this user\")\n              .optional(),\n        Option.shortcut(\"\")\n              .fullname(\"remove\")\n              .describe(\"USERNAME\")\n              .helptext(\"Do not consider pull request contributed to by this user\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var parser = new ArgumentParser(\"git-pr contributor\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n\n        if (arguments.contains(\"add\")) {\n            var username = ForgeUtils.getOption(\"add\", arguments);\n            var comment = pr.addComment(\"/contributor add\" + \" \" + username);\n            showReply(awaitReplyTo(pr, comment));\n        } else if (arguments.contains(\"remove\")) {\n            var username = ForgeUtils.getOption(\"remove\", arguments);\n            var comment = pr.addComment(\"/contributor remove\" + \" \" + username);\n            showReply(awaitReplyTo(pr, comment));\n        } else {\n            System.err.println(\"error: must use either --add or --remove\");\n            System.exit(1);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrCreate.java",
    "content": "/*\n * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.cli.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Label;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\npublic class GitPrCreate {\n    private static final Pattern BACKPORT_PATTERN = Pattern.compile(\"^Backport [0-9a-f]{40}$\");\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"u\")\n              .fullname(\"username\")\n              .describe(\"NAME\")\n              .helptext(\"Username on host\")\n              .optional(),\n        Option.shortcut(\"r\")\n              .fullname(\"remote\")\n              .describe(\"NAME\")\n              .helptext(\"Name of remote, defaults to 'origin'\")\n              .optional(),\n        Option.shortcut(\"b\")\n              .fullname(\"branch\")\n              .describe(\"NAME\")\n              .helptext(\"Name of target branch, defaults to '\" + Branch.defaultFor(VCS.GIT) + \"'\")\n              .optional(),\n        Option.shortcut(\"\")\n              .fullname(\"cc\")\n              .describe(\"MAILING LISTS\")\n              .helptext(\"Mailing lists to CC for inital RFR e-mail\")\n              .optional(),\n        Option.shortcut(\"\")\n              .fullname(\"template\")\n              .describe(\"TEMPLATE\")\n              .helptext(\"Template for pull request body\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"ignore-workspace\")\n              .helptext(\"Ignore local changes in worktree and staging area when creating pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"ignore-local-commits\")\n              .helptext(\"Ignore local commits not pushed when creating pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"publish\")\n              .helptext(\"Publish the local branch before creating the pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"jcheck\")\n              .helptext(\"Run jcheck before creating the pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"draft\")\n              .helptext(\"Create a pull request in draft state\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n\n    private static LabelConfiguration labelConfiguration(Forge forge, String project) throws IOException {\n        var group = project.split(\"/\")[0];\n        var skaraRemoteRepo = forge.repository(group + \"/skara\").orElseThrow(() ->\n            new IOException(\"error: could not resolve Skara repository\")\n        );\n        var rules = skaraRemoteRepo\n                .fileContents(\"config/mailinglist/rules/jdk.json\", Branch.defaultFor(VCS.GIT).name())\n                .orElseThrow(() -> new RuntimeException(\"Could not find config/mailinglist/rules/jdk.json on ref \"\n                        + Branch.defaultFor(VCS.GIT).name() + \" in repo \" + skaraRemoteRepo.name()));\n        var json = JSON.parse(rules);\n        return LabelConfigurationJson.from(json);\n    }\n\n    private static Set<String> suggestedLabels(ReadOnlyRepository repo, Forge forge, String project, String targetRef, String headRef) throws IOException {\n        var config = labelConfiguration(forge, project);\n        var baseHash = repo.resolve(targetRef).orElseThrow(() ->\n            new IOException(\"error: cannot resolve \" + targetRef)\n        );\n        var headHash = repo.resolve(headRef).orElseThrow(() ->\n            new IOException(\"error: cannot resolve \" + headRef)\n        );\n        var status = repo.status(baseHash, headHash);\n        var files = status.stream()\n                          .filter(e -> !e.status().isDeleted())\n                          .map(e -> e.target().path().get())\n                          .collect(Collectors.toSet());\n        return config.label(files);\n    }\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var parser = new ArgumentParser(\"git-pr create\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var remote = getRemote(repo, arguments);\n        var currentBranch = repo.currentBranch().orElseGet(() -> {\n                System.err.println(\"error: the repository is in a detached HEAD state\");\n                System.exit(1);\n                return null;\n        });\n\n        var remoteRepo = host.repository(ForgeUtils.projectName(uri)).orElseThrow(() ->\n                new IOException(\"Could not find repository at \" + uri.toString())\n        );\n        var parentRepo = remoteRepo.parent().orElseThrow(() ->\n                new IOException(\"error: remote repository \" + uri + \" is not a fork of any repository\")\n        );\n\n        var upstreamBranchNames = repo.remoteBranches(parentRepo.webUrl().toString())\n                                      .stream()\n                                      .map(r -> r.name())\n                                      .collect(Collectors.toSet());\n        if (upstreamBranchNames.contains(currentBranch.name())) {\n            System.err.println(\"error: you should not create pull requests from a branch present in the upstream repository.\");\n            System.err.println(\"\");\n            System.err.println(\"To create a local branch for your changes and restore the '\" + currentBranch.name() + \"' branch, run:\");\n            System.err.println(\"\");\n            System.err.println(\"    git checkout -b NAME-FOR-YOUR-LOCAL-BRANCH\");\n            System.err.println(\"    git branch --force \" + currentBranch.name() + \" origin/\" + currentBranch.name());\n            System.err.println(\"\");\n            System.exit(1);\n        }\n\n        var ignoreWorkspace = getSwitch(\"ignore-workspace\", \"create\", arguments);\n        if (!ignoreWorkspace) {\n            var diff = repo.diff(repo.head());\n            if (!diff.patches().isEmpty()) {\n                System.err.println(\"error: there are uncommitted changes in your working tree:\");\n                System.err.println(\"\");\n                for (var patch : diff.patches()) {\n                    var path = patch.target().path().isPresent() ?\n                        patch.target().path().get() : patch.source().path().get();\n                    System.err.println(\"    \" + patch.status().toString() + \" \" + path.toString());\n                }\n                System.err.println(\"\");\n                System.err.println(\"If these changes are meant to be part of the pull request, run:\");\n                System.err.println(\"\");\n                System.err.println(\"    git commit -am 'Forgot to add some changes'\");\n                System.err.println(\"\");\n                System.err.println(\"If these changes are *not* meant to be part of the pull request, run:\");\n                System.err.println(\"\");\n                System.err.println(\"    git stash\");\n                System.err.println(\"\");\n                System.err.println(\"(You can later restore the changes by running: git stash pop)\");\n                System.err.println(\"\");\n                System.err.println(\"If you want to ignore this error, run:\");\n                System.err.println(\"\");\n                System.err.println(\"     git config --global pr.create.ignore-workspace true\");\n                System.err.println(\"\");\n                System.exit(1);\n            }\n        }\n\n        var upstream = repo.upstreamFor(currentBranch);\n        var shouldPublish = getSwitch(\"publish\", \"create\", arguments);\n        if (upstream.isEmpty() && !shouldPublish) {\n            System.err.println(\"error: there is no remote branch for the local branch '\" + currentBranch.name() + \"'\");\n            System.err.println(\"\");\n            System.err.println(\"A remote branch must be present at \" + uri + \" to create a pull request\");\n            System.err.println(\"To create a remote branch and push the commits for your local branch, run:\");\n            System.err.println(\"\");\n            System.err.println(\"    git publish\");\n            System.err.println(\"\");\n            System.err.println(\"If you created the remote branch from another client, you must update this repository.\");\n            System.err.println(\"To update remote information for this repository, run:\");\n            System.err.println(\"\");\n            System.err.println(\"    git fetch \" + remote);\n            System.err.println(\"    git branch --set-upstream \" + currentBranch + \" \" + remote + \"/\" + currentBranch);\n            System.err.println(\"\");\n            System.err.println(\"If you want 'git pr create' to automatically publish branches, run:\");\n            System.err.println(\"\");\n            System.err.println(\"    git config --global pr.create.publish true\");\n            System.err.println(\"\");\n            System.exit(1);\n        }\n\n        var shouldIgnoreLocalCommits = getSwitch(\"ignore-local-commits\", \"create\", arguments);\n        if (!shouldIgnoreLocalCommits && !shouldPublish) {\n            var upstreamRefName = upstream.get().substring(remote.length() + 1);\n            repo.fetch(uri, upstreamRefName);\n\n            var branchCommits = repo.commits(upstream.get() + \"..\" + currentBranch.name()).asList();\n            if (!branchCommits.isEmpty()) {\n                System.err.println(\"error: there are local commits on branch '\" + currentBranch.name() + \"' not present in the remote repository \" + uri);\n                System.err.println(\"\");\n                System.err.println(\"All commits must be present in the remote repository to be part of the pull request\");\n                System.err.println(\"The following commits are not present in the remote repository:\");\n                System.err.println(\"\");\n                for (var commit : branchCommits) {\n                    System.err.println(\"- \" + commit.hash().abbreviate() + \": \" + commit.message().get(0));\n                }\n                System.err.println(\"\");\n                System.err.println(\"To push the above local commits to the remote repository, run:\");\n                System.err.println(\"\");\n                System.err.println(\"    git push \" + remote + \" \" + currentBranch.name());\n                System.err.println(\"\");\n                System.err.println(\"If you want to ignore this error, run:\");\n                System.err.println(\"\");\n                System.err.println(\"     git config --global pr.create.ignore-local-commits true\");\n                System.err.println(\"\");\n                System.exit(1);\n            }\n        }\n\n        var targetBranch = getOption(\"branch\", \"create\", arguments);\n        if (targetBranch == null) {\n            var remoteBranches = repo.branches(remote);\n            var candidates = new ArrayList<Branch>();\n            for (var b : remoteBranches) {\n                if (b.name().length() <= remote.length()) {\n                    continue;\n                }\n                var withoutRemotePrefix = b.name().substring(remote.length() + 1);\n                if (upstreamBranchNames.contains(withoutRemotePrefix)) {\n                    candidates.add(b);\n                }\n            }\n\n            var localBranches = repo.branches();\n            Branch closest = null;\n            var shortestDistance = Integer.MAX_VALUE;\n            for (var b : candidates) {\n                var from = b.name();\n                for (var localBranch : localBranches) {\n                    var trackingBranch = repo.upstreamFor(localBranch);\n                    if (trackingBranch.isPresent() &&\n                        trackingBranch.get().equals(b.name())) {\n                        from = localBranch.name();\n                    }\n                }\n                var distance = repo.commitMetadata(from + \"...\" + currentBranch.name()).size();\n                if (distance < shortestDistance) {\n                    closest = b;\n                    shortestDistance = distance;\n                }\n            }\n\n            if (closest != null) {\n                targetBranch = closest.name().substring(remote.length() + 1);\n            } else {\n                System.err.println(\"error: cannot automatically infer target branch\");\n                System.err.println(\"       use --branch to specify target branch\");\n                System.exit(1);\n            }\n        }\n\n        var headRef = upstream.isEmpty() ? currentBranch.name() : upstream.get();\n        var commits = repo.commits(targetBranch + \"..\" + headRef).asList();\n        if (commits.isEmpty()) {\n            System.err.println(\"error: no difference between branches \" + targetBranch + \" and \" + headRef);\n            System.err.println(\"       Cannot create an empty pull request, have you committed?\");\n            System.exit(1);\n        }\n\n        var shouldRunJCheck = getSwitch(\"jcheck\", \"create\", arguments);\n        if (shouldRunJCheck) {\n            var jcheckArgs = new String[]{ \"--ignore=branches,committer,reviewers,issues\", \"--rev\", targetBranch + \"..\" + headRef };\n            var err = GitJCheck.run(repo, jcheckArgs);\n            if (err != 0) {\n                System.exit(err);\n            }\n        }\n\n        var mailingLists = new ArrayList<String>();\n        var parentProject = ForgeUtils.projectName(parentRepo.url());\n        var isTargetingJDKRepo = parentProject.matches(\".*\\\\/jdk[0-9]*\");\n        var cc = getOption(\"cc\", \"create\", arguments);\n        var isCCManual = cc != null && !cc.equals(\"auto\");\n        if (!isTargetingJDKRepo && isCCManual) {\n            System.out.println(\"error: you cannot manually CC additional mailing lists for \" + parentProject);\n            System.exit(1);\n        }\n        if (isTargetingJDKRepo) {\n            if (isCCManual) {\n                var config = labelConfiguration(host, parentProject);\n                var lists = cc.split(\",\");\n                for (var input : lists) {\n                    var label = input;\n                    if (label.endsWith(\"@openjdk.org\")) {\n                        label = input.split(\"@\")[0];\n                    }\n                    if (label.endsWith(\"-dev\")) {\n                        label = label.replace(\"-dev\", \"\");\n                    }\n                    if (!config.isAllowed(label) && !config.isAllowed(label + \"-dev\")) {\n                        System.out.println(\"error: the mailing list \\\"\" + label +\n                                           \"-dev@openjdk.org\\\" is not applicable, aborting.\");\n                        System.exit(1);\n                    }\n                }\n                System.out.println(\"You have chosen the following mailing lists to be CC:d for the \\\"RFR\\\" e-mail:\");\n                for (var input : lists) {\n                    String list = null;\n                    if (input.endsWith(\"@openjdk.org\")) {\n                        list = input;\n                    } else if (input.endsWith(\"-dev\")) {\n                        list = input + \"@openjdk.org\";\n                    } else  {\n                        list = input + \"-dev@openjdk.org\";\n                    }\n                    System.out.println(\"- \" + list);\n                    mailingLists.add(list);\n                }\n            } else {\n                var suggested = suggestedLabels(repo, host, parentProject, targetBranch, headRef);\n                var labelNameToLabel = parentRepo.labels().stream().collect(Collectors.toMap(l -> l.name(), l -> l));\n                for (var label : suggested) {\n                    if (label.endsWith(\"-dev\")) {\n                        label = label.substring(0, label.length() - \"-dev\".length());\n                    }\n                    var desc = labelNameToLabel.getOrDefault(label, new Label(label)).description();\n                    if (desc.isPresent()) {\n                        mailingLists.add(desc.get());\n                    }\n                }\n                if (!mailingLists.isEmpty()) {\n                    System.out.println(\"The following mailing lists will be CC:d for the \\\"RFR\\\" e-mail:\");\n                    for (var list : mailingLists) {\n                        System.out.println(\"- \" + list);\n                    }\n                }\n            }\n            if (!mailingLists.isEmpty()) {\n                System.out.println(\"\");\n                System.out.print(\"Do you want to proceed with this mailing list selection? [Y/n]: \");\n                var scanner = new Scanner(System.in);\n                var answer = scanner.nextLine().toLowerCase();\n                while (!(answer.equals(\"y\") || answer.equals(\"n\") || answer.isEmpty())) {\n                    System.out.print(\"Please answer with 'y', 'n' or empty for the default choice: \");\n                    answer = scanner.nextLine().toLowerCase();\n                }\n                if (!(answer.isEmpty() || answer.equals(\"y\"))) {\n                    System.out.println(\"\");\n                    System.out.println(\"error: user not satisfied with mailing list selection, aborting.\");\n                    if (cc == null) {\n                        System.out.println(\"       To specify mailing lists manually, use the --cc option.\");\n                    } else if (cc.equals(\"auto\")) {\n                        System.out.println(\"       You have set --cc=auto, you can use --cc to specify mailing lists manually\");\n                    }\n                    System.exit(1);\n                }\n            }\n        }\n\n        var project = jbsProjectFromJcheckConf(repo, targetBranch);\n        var issue = getIssue(currentBranch, project);\n\n        var file = Files.createTempFile(\"PULL_REQUEST_\", \".md\");\n        var headCommit = commits.get(0);\n        var headCommitMessage = CommitMessageParsers.v1.parse(headCommit.message());\n        if (BACKPORT_PATTERN.matcher(headCommitMessage.title()).matches() && commits.size() == 1) {\n            Files.writeString(file, headCommitMessage.title() + \"\\n\\n\");\n        } else if (issue.isPresent()) {\n            Files.writeString(file, format(issue.get()) + \"\\n\\n\");\n        } else {\n            issue = getIssue(headCommit, project);\n            if (issue.isPresent()) {\n                Files.writeString(file, format(issue.get()) + \"\\n\\n\");\n            } else {\n                Files.writeString(file, headCommitMessage.title() + \"\\n\");\n                if (!headCommitMessage.summaries().isEmpty()) {\n                    Files.write(file, headCommitMessage.summaries(), StandardOpenOption.APPEND);\n                }\n                if (!headCommitMessage.additional().isEmpty()) {\n                    Files.write(file, headCommitMessage.additional(), StandardOpenOption.APPEND);\n                }\n            }\n        }\n\n        var templateOption = getOption(\"template\", \"create\", arguments);\n        if (templateOption != null) {\n            var templatePath = Path.of(templateOption).toAbsolutePath();\n            if (!Files.exists(templatePath)) {\n                System.err.println(\"error: file passed to --template does not exist: \" + templatePath);\n                System.exit(1);\n            }\n            if (!Files.isRegularFile(templatePath)) {\n                System.err.println(\"error: file passed to --template is not a regular file: \" + templatePath);\n                System.exit(1);\n            }\n            if (!Files.isReadable(templatePath)) {\n                System.err.println(\"error: file passed to --template is not readable: \" + templatePath);\n                System.exit(1);\n            }\n\n            var templateContent = Files.readString(Path.of(templateOption));\n            Files.writeString(file, \"\\n\", StandardOpenOption.APPEND);\n            Files.writeString(file, templateContent, StandardOpenOption.APPEND);\n            if (!templateContent.endsWith(\"\\n\")) {\n                Files.writeString(file, \"\\n\", StandardOpenOption.APPEND);\n            }\n        }\n\n        appendPaddedHTMLComment(file, \"Please enter the pull request message for your changes.\");\n        appendPaddedHTMLComment(file, \"The first line will be considered the title, use a blank line to\");\n        appendPaddedHTMLComment(file, \"separate the title from the body. Pull requests are required to have\");\n        appendPaddedHTMLComment(file, \"a title and a body. An empty message aborts the pull request.\");\n        appendPaddedHTMLComment(file, \"These HTML comment lines will be removed automatically.\");\n        appendPaddedHTMLComment(file, \"\");\n        appendPaddedHTMLComment(file, \"Commits to be included from branch '\" + currentBranch.name() + \"':\");\n        for (var commit : commits) {\n            var desc = commit.hash().abbreviate() + \": \" + commit.message().get(0);\n            appendPaddedHTMLComment(file, \"- \" + desc);\n            if (!commit.isMerge()) {\n                var diff = commit.parentDiffs().get(0);\n                for (var patch : diff.patches()) {\n                    var status = patch.status();\n                    if (status.isModified()) {\n                        appendPaddedHTMLComment(file, \"  M  \" + patch.target().path().get().toString());\n                    } else if (status.isAdded()) {\n                        appendPaddedHTMLComment(file, \"  A  \" + patch.target().path().get().toString());\n                    } else if (status.isDeleted()) {\n                        appendPaddedHTMLComment(file, \"  D  \" + patch.source().path().get().toString());\n                    } else if (status.isRenamed()) {\n                        appendPaddedHTMLComment(file, \"  R  \" + patch.target().path().get().toString());\n                        appendPaddedHTMLComment(file, \"      (\" + patch.source().path().get().toString() + \")\");\n                    } else if (status.isCopied()) {\n                        appendPaddedHTMLComment(file, \"  C  \" + patch.target().path().get().toString());\n                        appendPaddedHTMLComment(file, \"      (\" + patch.source().path().get().toString() + \")\");\n                    }\n                }\n            }\n        }\n        appendPaddedHTMLComment(file, \"\");\n        if (issue.isPresent()) {\n            appendPaddedHTMLComment(file, \"Issue:      \" + issue.get().webUrl());\n        }\n        appendPaddedHTMLComment(file, \"Repository: \" + parentRepo.webUrl());\n        appendPaddedHTMLComment(file, \"Branch:     \" + targetBranch);\n        if (!mailingLists.isEmpty()) {\n            appendPaddedHTMLComment(file, \"\");\n            appendPaddedHTMLComment(file, \"The following mailing lists will be CC:d for the \\\"RFR\\\" e-mail:\");\n            for (var list : mailingLists) {\n                appendPaddedHTMLComment(file, \"- \" + list);\n            }\n        }\n\n        var success = spawnEditor(repo, file);\n        if (!success) {\n            System.err.println(\"error: editor exited with non-zero status code, aborting\");\n            System.exit(1);\n        }\n        var lines = Files.readAllLines(file)\n                         .stream()\n                         .filter(l -> !(l.startsWith(\"<!--\") && l.endsWith(\"-->\")))\n                         .collect(Collectors.toList());\n        var isEmpty = lines.stream().allMatch(String::isEmpty);\n        if (isEmpty) {\n            System.err.println(\"error: no message present, aborting\");\n            System.exit(1);\n        }\n\n        var title = lines.get(0);\n        List<String> body = null;\n        if (lines.size() > 1) {\n            body = lines.subList(1, lines.size())\n                        .stream()\n                        .dropWhile(String::isEmpty)\n                        .collect(Collectors.toList());\n        } else {\n            System.err.println(\"error: cannot create pull request with empty body, aborting\");\n            System.exit(1);\n        }\n\n        if (isCCManual && !mailingLists.isEmpty()) {\n            var arg = mailingLists.stream()\n                                  .map(l -> l.split(\"@\")[0].replace(\"-dev\", \"\"))\n                                  .collect(Collectors.joining(\",\"));\n            body.add(\"/cc \" + arg);\n        }\n\n        var isDraft = getSwitch(\"draft\", \"create\", arguments);\n        if (upstream.isEmpty() && shouldPublish) {\n            GitPublish.main(new String[] { \"--quiet\", remote });\n        }\n        var pr = remoteRepo.createPullRequest(parentRepo, targetBranch, currentBranch.name(), title, body, isDraft);\n        var assigneesOption = getOption(\"assignees\", \"create\", arguments);\n        if (assigneesOption != null) {\n            var usernames = Arrays.asList(assigneesOption.split(\",\"));\n            var assignees = usernames.stream()\n                                     .map(u -> host.user(u))\n                                     .filter(Optional::isPresent)\n                                     .map(Optional::get)\n                                     .collect(Collectors.toList());\n            pr.setAssignees(assignees);\n        }\n        System.out.println(pr.webUrl().toString());\n        Files.deleteIfExists(file);\n\n        repo.config(\"pr.\" + currentBranch.name(), \"id\", pr.id().toString());\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrFetch.java",
    "content": "/*\n * Copyright (c) 2019, 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.util.List;\n\npublic class GitPrFetch {\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"u\")\n              .fullname(\"username\")\n              .describe(\"NAME\")\n              .helptext(\"Username on host\")\n              .optional(),\n        Option.shortcut(\"r\")\n              .fullname(\"remote\")\n              .describe(\"NAME\")\n              .helptext(\"Name of remote, defaults to 'origin'\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"no-token\")\n              .helptext(\"Do not use a personal access token (PAT)\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException {\n        var parser = new ArgumentParser(\"git-pr fetch\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n\n        var fetchHead = repo.fetch(pr.repository().webUrl(), pr.fetchRef()).orElseThrow();\n        System.out.println(fetchHead.hex());\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrHelp.java",
    "content": "/*\n * Copyright (c) 2020, 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.cli.GitPr;\nimport org.openjdk.skara.version.Version;\nimport org.openjdk.skara.cli.Logging;\n\nimport java.util.*;\nimport java.util.logging.Level;\nimport java.util.stream.Collectors;\n\npublic class GitPrHelp {\n    private static final class Pair<T1, T2> {\n        T1 e1;\n        T2 e2;\n\n        Pair(T1 e1, T2 e2) {\n            this.e1 = e1;\n            this.e2 = e2;\n        }\n\n        static <T3, T4> Pair<T3, T4> of(T3 e1, T4 e2) {\n            return new Pair<>(e1, e2);\n        }\n\n        T1 first() {\n            return e1;\n        }\n\n        T2 second() {\n            return e2;\n        }\n    }\n\n    private static final Map<String, Pair<List<Input>, List<Flag>>> commands = new HashMap<>();\n\n    static {\n        commands.put(\"list\", Pair.of(GitPrList.inputs, GitPrList.flags));\n        commands.put(\"fetch\", Pair.of(GitPrFetch.inputs, GitPrFetch.flags));\n        commands.put(\"show\", Pair.of(GitPrShow.inputs, GitPrShow.flags));\n        commands.put(\"checkout\", Pair.of(GitPrCheckout.inputs, GitPrCheckout.flags));\n        commands.put(\"apply\", Pair.of(GitPrApply.inputs, GitPrApply.flags));\n        commands.put(\"integrate\", Pair.of(GitPrIntegrate.inputs, GitPrIntegrate.flags));\n        commands.put(\"review\", Pair.of(GitPrReview.inputs, GitPrReview.flags));\n        commands.put(\"create\", Pair.of(GitPrCreate.inputs, GitPrCreate.flags));\n        commands.put(\"close\", Pair.of(GitPrClose.inputs, GitPrClose.flags));\n        commands.put(\"set\", Pair.of(GitPrSet.inputs, GitPrSet.flags));\n        commands.put(\"sponsor\", Pair.of(GitPrSponsor.inputs, GitPrSponsor.flags));\n        commands.put(\"test\", Pair.of(GitPrTest.inputs, GitPrTest.flags));\n        commands.put(\"info\", Pair.of(GitPrInfo.inputs, GitPrInfo.flags));\n        commands.put(\"issue\", Pair.of(GitPrIssue.inputs, GitPrIssue.flags));\n        commands.put(\"reviewer\", Pair.of(GitPrReviewer.inputs, GitPrReviewer.flags));\n        commands.put(\"summary\", Pair.of(GitPrSummary.inputs, GitPrSummary.flags));\n        commands.put(\"cc\", Pair.of(GitPrCC.inputs, GitPrCC.flags));\n        commands.put(\"csr\", Pair.of(GitPrCSR.inputs, GitPrCSR.flags));\n        commands.put(\"contributor\", Pair.of(GitPrContributor.inputs, GitPrContributor.flags));\n    }\n\n    private static String describe(List<Input> inputs) {\n        return inputs.stream().map(Input::toString).collect(Collectors.joining(\" \"));\n    }\n\n    private static<T> TreeSet<T> sorted(Set<T> s) {\n        return new TreeSet<>(s);\n    }\n\n    private static void showHelpFor(String command, int indentation) {\n        var inputs = commands.get(command).first();\n        var flags = commands.get(command).second();\n\n        System.out.println(\" \".repeat(indentation) + \"Usage: git pr \" + command + \" \" + describe(inputs));\n        System.out.println(\" \".repeat(indentation) + \"Flags:\");\n        ArgumentParser.showFlags(System.out, flags, \" \".repeat(indentation + 2));\n    }\n\n    public static void main(String[] args) {\n        var flags = List.of(\n            Switch.shortcut(\"h\")\n                  .fullname(\"help\")\n                  .helptext(\"Show help\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"verbose\")\n                  .helptext(\"Turn on verbose output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"debug\")\n                  .helptext(\"Turn on debugging output\")\n                  .optional(),\n            Switch.shortcut(\"\")\n                  .fullname(\"version\")\n                  .helptext(\"Print the version of this tool\")\n                  .optional()\n        );\n\n        var inputs = List.of(\n            Input.position(0)\n                 .describe(\"COMMAND\")\n                 .singular()\n                 .optional()\n        );\n\n        var parser = new ArgumentParser(\"git-pr help\", flags, inputs);\n        var arguments = parser.parse(args);\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-pr version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            Logging.setup(level);\n        }\n\n        if (arguments.at(0).isPresent()) {\n            var command = arguments.at(0).asString();\n            if (commands.keySet().contains(command)) {\n               System.out.println(\"pr \" + command + \" -- \" + GitPr.getHelpForCommand(command));\n               showHelpFor(command, 0);\n               System.exit(0);\n            } else {\n                System.err.println(\"error: unknown sub-command: \" + command);\n                System.err.println(\"\");\n                System.err.println(\"Available sub-commands are:\");\n                for (var subcommand : sorted(commands.keySet())) {\n                    System.err.println(\"- \" + subcommand);\n                }\n                System.exit(1);\n            }\n        }\n\n        System.out.println(\"git-pr is used for interacting with pull requests from a command line.\");\n        System.out.println(\"The following sub-commands are available:\");\n        for (var command : sorted(commands.keySet())) {\n            System.out.println();\n            System.out.println(\"- \" + command + \" -- \" + GitPr.getHelpForCommand(command));\n            showHelpFor(command, 2);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrInfo.java",
    "content": "/*\n * Copyright (c) 2019, 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.forge.PullRequestBody;\nimport org.openjdk.skara.args.*;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.net.URI;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\npublic class GitPrInfo {\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"u\")\n              .fullname(\"username\")\n              .describe(\"NAME\")\n              .helptext(\"Username on host\")\n              .optional(),\n        Option.shortcut(\"r\")\n              .fullname(\"remote\")\n              .describe(\"NAME\")\n              .helptext(\"Name of remote, defaults to 'origin'\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"no-decoration\")\n              .helptext(\"Hide any decorations when listing PRs\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"no-token\")\n              .helptext(\"Do not use a personal access token (PAT)\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"checks\")\n              .helptext(\"Show information about checks\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"author\")\n              .helptext(\"Show the author of the pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"title\")\n              .helptext(\"Show the title of the pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"assignees\")\n              .helptext(\"Show the assignees of the pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"reviewers\")\n              .helptext(\"Show the reviewers of the pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"contributors\")\n              .helptext(\"Show the additional contributors to the pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"issues\")\n              .helptext(\"Show the issues associated with the pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"commits\")\n              .helptext(\"Show the commits in pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"branch\")\n              .helptext(\"Show the target branch for the pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"url\")\n              .helptext(\"Show the url for the pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"status\")\n              .helptext(\"Show the status for the pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"labels\")\n              .helptext(\"Show the labels for the pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException {\n        var parser = new ArgumentParser(\"git-pr info\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n        var body = PullRequestBody.parse(pr);\n\n        var showDecoration = !getSwitch(\"no-decoration\", \"info\", arguments);\n        var showChecks = getSwitch(\"checks\", \"info\", arguments);\n        var showTitle = getSwitch(\"title\", \"info\", arguments);\n        var showUrl = getSwitch(\"url\", \"info\", arguments);\n        var showLabels = getSwitch(\"labels\", \"info\", arguments);\n        var showAssignees = getSwitch(\"assignees\", \"info\", arguments);\n        var showReviewers = getSwitch(\"reviewers\", \"info\", arguments);\n        var showBranch = getSwitch(\"branch\", \"info\", arguments);\n        var showCommits = getSwitch(\"commits\", \"info\", arguments);\n        var showAuthor = getSwitch(\"author\", \"info\", arguments);\n        var showStatus = getSwitch(\"status\", \"info\", arguments);\n        var showIssues = getSwitch(\"issues\", \"info\", arguments);\n        var showContributors = getSwitch(\"contributors\", \"info\", arguments);\n        var showAll = !showTitle && !showUrl && !showLabels && !showAssignees &&\n                      !showReviewers && !showBranch && !showCommits && !showAuthor &&\n                      !showStatus && !showIssues && !showContributors;\n\n        var decorations = new ArrayList<String>();\n        if (showAll || showTitle) {\n            decorations.add(\"Title: \");\n        }\n        if (showAll || showUrl) {\n            decorations.add(\"Url: \");\n        }\n        if (showAll || showAuthor) {\n            decorations.add(\"Author: \");\n        }\n        if (showAll || showBranch) {\n            decorations.add(\"Branch: \");\n        }\n        if (showAll || showLabels) {\n            decorations.add(\"Labels: \");\n        }\n        if (showAll || showAssignees) {\n            decorations.add(\"Assignees: \");\n        }\n        if (showAll || showReviewers) {\n            decorations.add(\"Reviewers: \");\n        }\n        if (showAll || showStatus) {\n            decorations.add(\"Status: \");\n        }\n        if (showAll || showChecks) {\n            decorations.add(\"Checks: \");\n        }\n        if (showAll || showCommits) {\n            decorations.add(\"Commits: \");\n        }\n        if (showAll || showIssues) {\n            decorations.add(\"Issues: \");\n        }\n        if (showAll || showContributors) {\n            decorations.add(\"Contributors: \");\n        }\n\n        var longest = decorations.stream()\n                                 .mapToInt(String::length)\n                                 .max()\n                                 .orElse(0);\n        var fmt = \"%-\" + longest + \"s\";\n\n        if (showAll || showUrl) {\n            if (showDecoration) {\n                System.out.format(fmt, \"URL:\");\n            }\n            System.out.println(pr.webUrl());\n        }\n\n        if (showAll || showTitle) {\n            if (showDecoration) {\n                System.out.format(fmt, \"Title:\");\n            }\n            System.out.println(pr.title());\n        }\n\n        if (showAll || showAuthor) {\n            if (showDecoration) {\n                System.out.format(fmt, \"Author:\");\n            }\n            System.out.println(pr.author().username());\n        }\n\n        if (showAll || showBranch) {\n            if (showDecoration) {\n                System.out.format(fmt, \"Branch:\");\n            }\n            System.out.println(pr.targetRef());\n        }\n\n        if (showAll || showLabels) {\n            if (showDecoration) {\n                System.out.format(fmt, \"Labels:\");\n            }\n            System.out.println(String.join(\", \", pr.labelNames()));\n        }\n\n        if (showAll || showAssignees) {\n            if (showDecoration) {\n                System.out.format(fmt, \"Assignees:\");\n            }\n            var usernames = pr.assignees().stream().map(u -> u.username()).collect(Collectors.toList());\n            if (usernames.isEmpty()) {\n                System.out.println(\"-\");\n            } else {\n                System.out.println(String.join(\", \", usernames));\n            }\n        }\n\n        if (showAll || showReviewers) {\n            if (showDecoration) {\n                System.out.format(fmt, \"Reviewers:\");\n            }\n            var usernames = pr.reviews().stream().map(u -> u.reviewer().username()).collect(Collectors.toList());\n            if (usernames.isEmpty()) {\n                System.out.println(\"-\");\n            } else {\n                System.out.println(String.join(\", \", usernames));\n            }\n        }\n\n        if (showAll || showContributors) {\n            if (showDecoration) {\n                System.out.format(fmt, \"Contributors:\");\n            }\n            if (body.contributors().isEmpty()) {\n                System.out.println(\"-\");\n            } else {\n                System.out.println(String.join(\", \", body.contributors()));\n            }\n        }\n\n        if (showAll || showStatus) {\n            if (showDecoration) {\n                System.out.format(fmt, \"Status:\");\n            }\n            System.out.println(statusForPullRequest(pr));\n        }\n\n        if (showAll || showIssues) {\n            var issues = body.issues()\n                             .stream()\n                             .map(URI::getPath)\n                             .map(Path::of)\n                             .map(Path::getFileName)\n                             .map(Path::toString)\n                             .collect(Collectors.toList());\n            if (issues.size() == 0 || issues.size() == 1) {\n                if (showDecoration) {\n                    System.out.format(fmt, \"Issue:\");\n                }\n                if (issues.size() == 0) {\n                    System.out.println(\"-\");\n                } else {\n                    System.out.println(issues.get(0));\n                }\n            } else {\n                System.out.println(\"Issues:\");\n                for (var issue : issues) {\n                    System.out.println(\"- \" + issue);\n                }\n            }\n        }\n\n        if (showAll || showChecks) {\n            var checks = pr.checks(pr.headHash());\n            var jcheck = Optional.ofNullable(checks.get(\"jcheck\"));\n            var submit = Optional.ofNullable(checks.get(\"submit\"));\n            if (jcheck.isPresent() || submit.isPresent()) {\n                System.out.println(\"Checks:\");\n                if (jcheck.isPresent()) {\n                    System.out.println(\"- jcheck: \" + statusForCheck(jcheck.get()));\n                }\n                if (submit.isPresent()) {\n                    System.out.println(\"- submit: \" + statusForCheck(submit.get()));\n                }\n            }\n        }\n\n        if (showAll || showCommits) {\n            var url = pr.repository().webUrl();\n            var target = repo.fetch(url, pr.targetRef()).orElseThrow();\n            var head = repo.fetch(url, pr.fetchRef()).orElseThrow();\n            var mergeBase = repo.mergeBase(head, target);\n            var commits = repo.commitMetadata(mergeBase, head);\n            if (showDecoration) {\n                System.out.println(\"Commits:\");\n            }\n            for (var commit : commits) {\n                System.out.println(\"- \" + commit.hash().abbreviate() + \": \" + commit.message().get(0));\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrIntegrate.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.util.List;\nimport java.util.regex.Pattern;\n\npublic class GitPrIntegrate {\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"u\")\n              .fullname(\"username\")\n              .describe(\"NAME\")\n              .helptext(\"Username on host\")\n              .optional(),\n        Option.shortcut(\"r\")\n              .fullname(\"remote\")\n              .describe(\"NAME\")\n              .helptext(\"Name of remote, defaults to 'origin'\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"atomic\")\n              .helptext(\"Integrate the pull request atomically\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"auto\")\n              .helptext(\"Configure the pull request for automatic integration\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"manual\")\n              .helptext(\"Configure the pull request for manual integration\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var parser = new ArgumentParser(\"git-pr integrate\", flags, inputs);\n        var arguments = parse(parser, args);\n\n        var isAtomic = getSwitch(\"atomic\", \"integrate\", arguments);\n        var isAuto = getSwitch(\"auto\", \"integrate\", arguments);\n        var isManual = getSwitch(\"manual\", \"integrate\", arguments);\n        if (isAuto && isManual) {\n            exit(\"error: cannot use both --auto and --manual\");\n        }\n        if (isAtomic) {\n            if (isAuto) {\n                exit(\"error: cannot use both --atomic and --auto\");\n            }\n            if (isManual) {\n                exit(\"error: cannot use both --atomic and --manual\");\n            }\n        }\n\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n\n        var message = \"/integrate\";\n        if (isAtomic) {\n            var targetHash = repo.resolve(pr.targetRef());\n            if (!targetHash.isPresent()) {\n                exit(\"error: cannot resolve target branch \" + pr.targetRef());\n            }\n            var sourceHash = repo.fetch(pr.repository().webUrl(), pr.fetchRef()).orElseThrow();\n            var mergeBase = repo.mergeBase(sourceHash, targetHash.get());\n            message += \" \" + mergeBase.hex();\n        } else if (isAuto) {\n            message += \" auto\";\n        } else if (isManual) {\n            message += \" manual\";\n        }\n\n        var integrateComment = pr.addComment(message);\n\n        var seenIntegrateComment = false;\n        var expected = \"<!-- Jmerge command reply message (\" + integrateComment.id() + \") -->\";\n        var pushedPattern = Pattern.compile(\"Pushed as commit ([a-f0-9]{40})\\\\.\");\n        var sponsorPattern = Pattern.compile(\"Your change \\\\(at version ([a-f0-9]{40})\\\\) is now ready to be sponsored by a Committer\\\\.\");\n        for (var i = 0; i < 90; i++) {\n            var comments = pr.comments();\n            for (var comment : comments) {\n                if (!seenIntegrateComment) {\n                    if (comment.id().equals(integrateComment.id())) {\n                        seenIntegrateComment = true;\n                    }\n                    continue;\n                }\n                var lines = comment.body().split(\"\\n\");\n                if (lines.length > 0 && lines[0].equals(expected)) {\n                    for (var line : lines) {\n                        if (pushedPattern.matcher(line).find()) {\n                            var output = removeTrailing(line, \".\");\n                            System.out.println(output);\n                            System.exit(0);\n                        } else if (sponsorPattern.matcher(line).find()) {\n                            var output = removeTrailing(line, \".\");\n                            System.out.println(output);\n                            System.exit(0);\n                        }\n                    }\n                }\n            }\n\n            Thread.sleep(2000);\n        }\n\n        System.err.println(\"error: timed out waiting for response to /integrate command\");\n        System.exit(1);\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrIssue.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.cli.ForgeUtils;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.util.*;\n\npublic class GitPrIssue {\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"\")\n              .fullname(\"add\")\n              .describe(\"ID\")\n              .helptext(\"Consider issue solved by this pull request\")\n              .optional(),\n        Option.shortcut(\"\")\n              .fullname(\"remove\")\n              .describe(\"ID\")\n              .helptext(\"Do not consider issue as solved by this pull request\")\n              .optional(),\n        Option.shortcut(\"\")\n              .fullname(\"priority\")\n              .describe(\"1|2|3|4|5\")\n              .helptext(\"Priority for issue\")\n              .optional(),\n        Option.shortcut(\"\")\n              .fullname(\"component\")\n              .describe(\"NAME\")\n              .helptext(\"Component for issue\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var parser = new ArgumentParser(\"git-pr issue\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n\n        if (arguments.contains(\"add\")) {\n            var issueId = ForgeUtils.getOption(\"add\", arguments);\n            var comment = pr.addComment(\"/issue add\" + \" \" + issueId);\n            showReply(awaitReplyTo(pr, comment));\n        } else if (arguments.contains(\"remove\")) {\n            var issueId = ForgeUtils.getOption(\"remove\", arguments);\n            var comment = pr.addComment(\"/issue remove\" + \" \" + issueId);\n            showReply(awaitReplyTo(pr, comment));\n        } else {\n            System.err.println(\"error: must use either --add or --remove\");\n            System.exit(1);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrList.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.cli.ForgeUtils;\nimport org.openjdk.skara.host.HostUser;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\npublic class GitPrList {\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"u\")\n              .fullname(\"username\")\n              .describe(\"NAME\")\n              .helptext(\"Username on host\")\n              .optional(),\n        Option.shortcut(\"r\")\n              .fullname(\"remote\")\n              .describe(\"NAME\")\n              .helptext(\"Name of remote, defaults to 'origin'\")\n              .optional(),\n        Option.shortcut(\"\")\n              .fullname(\"authors\")\n              .describe(\"LIST\")\n              .helptext(\"Comma separated list of authors\")\n              .optional(),\n        Option.shortcut(\"\")\n              .fullname(\"assignees\")\n              .describe(\"LIST\")\n              .helptext(\"Comma separated list of assignees\")\n              .optional(),\n        Option.shortcut(\"\")\n              .fullname(\"labels\")\n              .describe(\"LIST\")\n              .helptext(\"Comma separated list of labels\")\n              .optional(),\n        Option.shortcut(\"\")\n              .fullname(\"issues\")\n              .describe(\"LIST\")\n              .helptext(\"Comma separated list of issues\")\n              .optional(),\n        Option.shortcut(\"\")\n              .fullname(\"columns\")\n              .describe(\"id,title,author,assignees,labels\")\n              .helptext(\"Comma separated list of columns to show\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"no-decoration\")\n              .helptext(\"Hide any decorations when listing PRs\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"no-draft\")\n              .helptext(\"Hide all pull requests in draft state\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"no-token\")\n              .helptext(\"Do not use a personal access token (PAT)\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional());\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    private static int longest(List<String> strings) {\n        return strings.stream().mapToInt(String::length).max().orElse(0);\n    }\n\n    public static void main(String[] args) throws IOException {\n        var parser = new ArgumentParser(\"git-pr list\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var remoteRepo = ForgeUtils.getHostedRepositoryFor(uri, repo, host);\n\n        var prs = remoteRepo.openPullRequests();\n        var ids = new ArrayList<String>();\n        var titles = new ArrayList<String>();\n        var authors = new ArrayList<String>();\n        var assignees = new ArrayList<String>();\n        var labels = new ArrayList<String>();\n        var issues = new ArrayList<String>();\n        var branches = new ArrayList<String>();\n        var statuses = new ArrayList<String>();\n        var urls = new ArrayList<String>();\n        var noDraft = getSwitch(\"no-draft\", \"list\", arguments);\n\n        var authorsOption = getOption(\"authors\", \"list\", arguments);\n        var filterAuthors = authorsOption == null ?\n            Set.of() :\n            new HashSet<>(Arrays.asList(authorsOption.split(\",\")));\n\n        var assigneesOption = getOption(\"assignees\", \"list\", arguments);\n        var filterAssignees = assigneesOption == null ?\n            Set.of() :\n            Arrays.asList(assigneesOption.split(\",\"));\n\n        var labelsOption = getOption(\"labels\", \"list\", arguments);\n        var filterLabels = labelsOption == null ?\n            Set.of() :\n            Arrays.asList(labelsOption.split(\",\"));\n\n        var issuesOption = getOption(\"issues\", \"list\", arguments);\n        var filterIssues = issuesOption == null ?\n            Set.of() :\n            Arrays.asList(issuesOption.split(\",\"));\n\n        var columnTitles = List.of(\"id\", \"title\", \"authors\", \"assignees\", \"labels\", \"issues\", \"branch\", \"status\", \"url\");\n        var columnValues = Map.of(columnTitles.get(0), ids,\n                                  columnTitles.get(1), titles,\n                                  columnTitles.get(2), authors,\n                                  columnTitles.get(3), assignees,\n                                  columnTitles.get(4), labels,\n                                  columnTitles.get(5), issues,\n                                  columnTitles.get(6), branches,\n                                  columnTitles.get(7), statuses,\n                                  columnTitles.get(8), urls);\n        var columnsOption = getOption(\"columns\", \"list\", arguments);\n        var columns = columnsOption == null ?\n            List.of(\"id\", \"title\", \"authors\", \"status\") :\n            Arrays.asList(columnsOption.split(\",\"));\n\n        for (var column : columns) {\n            if (!columnTitles.contains(column)) {\n                System.err.println(\"error: unknown column: \" + column);\n                System.err.println(\"       available columns are: \" + String.join(\",\", columnTitles));\n                System.exit(1);\n            }\n        }\n\n        for (var pr : prs) {\n            if (pr.isDraft() && noDraft) {\n                continue;\n            }\n\n            var prAuthor = pr.author().username();\n            if (!filterAuthors.isEmpty() && !filterAuthors.contains(prAuthor)) {\n                continue;\n            }\n\n            var prAssignees = pr.assignees().stream()\n                                .map(HostUser::username)\n                                .collect(Collectors.toSet());\n            if (!filterAssignees.isEmpty() && !filterAssignees.stream().anyMatch(prAssignees::contains)) {\n                continue;\n            }\n\n            var prLabels = pr.labelNames();\n            if (!filterLabels.isEmpty() && !filterLabels.stream().anyMatch(prLabels::contains)) {\n                continue;\n            }\n\n            var prIssues = new HashSet<>(issuesFromPullRequest(pr));\n            if (!filterIssues.isEmpty() && !filterIssues.stream().anyMatch(prIssues::contains)) {\n                continue;\n            }\n\n\n            ids.add(pr.id());\n            titles.add(pr.title());\n            authors.add(prAuthor);\n            assignees.add(String.join(\",\", prAssignees));\n            labels.add(String.join(\",\", prLabels));\n            issues.add(String.join(\",\", prIssues));\n            urls.add(pr.webUrl().toString());\n\n            if (pr.sourceRepository().isPresent() && pr.sourceRepository().get().webUrl().equals(uri)) {\n                branches.add(pr.sourceRef());\n            } else {\n                branches.add(\"\");\n            }\n\n            if (columns.contains(\"status\")) {\n                statuses.add(statusForPullRequest(pr).toLowerCase());\n            } else {\n                statuses.add(\"\");\n            }\n        }\n\n\n        String fmt = \"\";\n        for (var column : columns.subList(0, columns.size() - 1)) {\n            var values = columnValues.get(column);\n            var n = Math.max(column.length(), longest(values));\n            fmt += \"%-\" + n + \"s    \";\n        }\n        fmt += \"%s\\n\";\n\n        var noDecoration = getSwitch(\"no-decoration\", \"list\", arguments);\n        if (!ids.isEmpty() && !noDecoration) {\n            var upperCase = columns.stream()\n                                   .map(String::toUpperCase)\n                                   .collect(Collectors.toList());\n            System.out.format(fmt, (Object[]) upperCase.toArray(new String[0]));\n        }\n        for (var i = 0; i < ids.size(); i++) {\n            final int n = i;\n            var row = columns.stream()\n                             .map(columnValues::get)\n                             .map(values -> values.get(n))\n                             .collect(Collectors.toList());\n            System.out.format(fmt, (Object[]) row.toArray(new String[0]));\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrReview.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.cli.ForgeUtils;\nimport org.openjdk.skara.forge.Review;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.util.List;\n\npublic class GitPrReview {\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"u\")\n              .fullname(\"username\")\n              .describe(\"NAME\")\n              .helptext(\"Username on host\")\n              .optional(),\n        Option.shortcut(\"r\")\n              .fullname(\"remote\")\n              .describe(\"NAME\")\n              .helptext(\"Name of remote, defaults to 'origin'\")\n              .optional(),\n        Option.shortcut(\"m\")\n              .fullname(\"message\")\n              .describe(\"TEXT\")\n              .helptext(\"Message to author as part of review (e.g. \\\"Looks good!\\\")\")\n              .optional(),\n        Option.shortcut(\"t\")\n              .fullname(\"type\")\n              .describe(\"TEXT\")\n              .helptext(\"Select the review type: 'approve' or 'request-changes' or 'comment'\")\n              .required(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException {\n        var parser = new ArgumentParser(\"git-pr review\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n\n        var message = ForgeUtils.getOption(\"message\", arguments);\n        var type = ForgeUtils.getOption(\"type\", arguments);\n        checkType(type, parser);\n        if (\"approve\".equals(type)) {\n            pr.addReview(Review.Verdict.APPROVED, message);\n        } else if (\"request-changes\".equals(type)) {\n            checkMessage(message, type, parser);\n            pr.addReview(Review.Verdict.DISAPPROVED, message);\n        } else if (\"comment\".equals(type)) {\n            checkMessage(message, type, parser);\n            pr.addReview(Review.Verdict.NONE, message);\n        }\n    }\n\n    /**\n     * The message can't be null if the type is `request-change` or `comment`.\n     */\n    public static void checkMessage(String message, String type, ArgumentParser parser) {\n        if (message == null) {\n            System.err.println(\"error: the option 'message' missed. Need to provide the 'message' if the 'type' is '\" + type + \"'.\");\n            parser.showUsage();\n            System.exit(1);\n        }\n    }\n\n    /**\n     * The type need to be `approve` or `request-change` or `comment`.\n     */\n    public static void checkType(String type, ArgumentParser parser) {\n        if (\"approve\".equals(type) || \"request-changes\".equals(type) || \"comment\".equals(type)) {\n            return;\n        }\n        System.err.println(\"error: incorrect review 'type': '\" + type\n                + \"'. Supported review types:  \\\"approve\\\", \\\"request-changes\\\" or \\\"comment\\\".\");\n        parser.showUsage();\n        System.exit(1);\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrReviewer.java",
    "content": "/*\n * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.cli.ForgeUtils;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.util.*;\n\npublic class GitPrReviewer {\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"\")\n              .fullname(\"credit\")\n              .describe(\"USERNAME\")\n              .helptext(\"Credit a person as a reviewer of this pull request\")\n              .optional(),\n        Option.shortcut(\"\")\n              .fullname(\"remove\")\n              .describe(\"USERNAME\")\n              .helptext(\"Do not consider pull request reviewed by this user\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var parser = new ArgumentParser(\"git-pr reviewer\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n\n        if (arguments.contains(\"credit\")) {\n            var username = ForgeUtils.getOption(\"credit\", arguments);\n            var comment = pr.addComment(\"/reviewer credit\" + \" \" + username);\n            showReply(awaitReplyTo(pr, comment));\n        } else if (arguments.contains(\"remove\")) {\n            var username = ForgeUtils.getOption(\"remove\", arguments);\n            var comment = pr.addComment(\"/reviewer remove\" + \" \" + username);\n            showReply(awaitReplyTo(pr, comment));\n        } else {\n            System.err.println(\"error: must use either --credit or --remove\");\n            System.exit(1);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrSet.java",
    "content": "/*\n * Copyright (c) 2019, 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.issuetracker.IssueTracker;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.jcheck.JCheckConfiguration;\nimport org.openjdk.skara.proxy.HttpProxy;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\nimport org.openjdk.skara.version.Version;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.concurrent.TimeUnit;\nimport java.util.logging.Level;\nimport java.util.regex.Matcher;\nimport java.util.stream.Collectors;\n\npublic class GitPrSet {\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"u\")\n              .fullname(\"username\")\n              .describe(\"NAME\")\n              .helptext(\"Username on host\")\n              .optional(),\n        Option.shortcut(\"r\")\n              .fullname(\"remote\")\n              .describe(\"NAME\")\n              .helptext(\"Name of remote, defaults to 'origin'\")\n              .optional(),\n        Option.shortcut(\"\")\n              .fullname(\"assignees\")\n              .describe(\"LIST\")\n              .helptext(\"Comma separated list of assignees\")\n              .optional(),\n        Option.shortcut(\"\")\n              .fullname(\"title\")\n              .describe(\"MESSAGE\")\n              .helptext(\"The title of the pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"open\")\n              .helptext(\"Set the pull request's state to open\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"closed\")\n              .helptext(\"Set the pull request's state to closed\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"body\")\n              .helptext(\"Set the body of the pull request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"no-draft\")\n              .helptext(\"Mark the pull request as not draft\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"clean\")\n              .helptext(\"Set a backport pull request as clean\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var parser = new ArgumentParser(\"git-pr set\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n\n        var assigneesOption = getOption(\"assignees\", \"set\", arguments);\n        if (assigneesOption == null) {\n            pr.setAssignees(List.of());\n        } else {\n            var usernames = Arrays.asList(assigneesOption.split(\",\"));\n            var assignees = usernames.stream()\n                .map(u -> host.user(u))\n                .filter(Optional::isPresent)\n                .map(Optional::get)\n                .collect(Collectors.toList());\n            pr.setAssignees(assignees);\n        }\n\n        var title = getOption(\"title\", \"set\", arguments);\n        if (title != null) {\n            pr.setTitle(title);\n        }\n\n        var setOpen = getSwitch(\"open\", \"set\", arguments);\n        if (setOpen) {\n            pr.setState(PullRequest.State.OPEN);\n        }\n\n        var setClosed = getSwitch(\"closed\", \"set\", arguments);\n        if (setClosed) {\n            pr.setState(PullRequest.State.CLOSED);\n        }\n\n        var setClean = getSwitch(\"clean\", \"set\", arguments);\n        if (setClean) {\n            var command = pr.addComment(\"/clean\");\n            var reply = awaitReplyTo(pr, command);\n            showReply(reply);\n        }\n\n        var setBody = getSwitch(\"body\", \"set\", arguments);\n        if (setBody) {\n            var file = Files.createTempFile(\"PULL_REQUEST_\", \".md\");\n            Files.writeString(file, pr.body());\n            var success = spawnEditor(repo, file);\n            if (!success) {\n                System.err.println(\"error: editor exited with non-zero status code, aborting\");\n                System.exit(1);\n            }\n            var content = Files.readString(file);\n            if (content.isEmpty()) {\n                System.err.println(\"error: no message present, aborting\");\n                System.exit(1);\n            }\n            pr.setBody(content);\n        }\n\n        var setNoDraft = getSwitch(\"no-draft\", \"set\", arguments);\n        if (setNoDraft) {\n            pr.makeNotDraft();\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrShow.java",
    "content": "/*\n * Copyright (c) 2019, 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.util.List;\n\npublic class GitPrShow {\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"u\")\n              .fullname(\"username\")\n              .describe(\"NAME\")\n              .helptext(\"Username on host\")\n              .optional(),\n        Option.shortcut(\"r\")\n              .fullname(\"remote\")\n              .describe(\"NAME\")\n              .helptext(\"Name of remote, defaults to 'origin'\")\n              .optional(),\n        Switch.shortcut(\"t\")\n              .fullname(\"tool\")\n              .helptext(\"Use git diftool (GUI) instead of git diff\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"no-token\")\n              .helptext(\"Do not use a personal access token (PAT)\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var parser = new ArgumentParser(\"git-pr show\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n        var useTool = getSwitch(\"tool\", \"show\", arguments);\n\n        var fetchHead = repo.fetch(pr.repository().url(), pr.fetchRef()).orElseThrow();\n        show(pr.targetRef(), fetchHead, useTool);\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrSponsor.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.util.List;\nimport java.util.regex.Pattern;\n\npublic class GitPrSponsor {\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"u\")\n              .fullname(\"username\")\n              .describe(\"NAME\")\n              .helptext(\"Username on host\")\n              .optional(),\n        Option.shortcut(\"r\")\n              .fullname(\"remote\")\n              .describe(\"NAME\")\n              .helptext(\"Name of remote, defaults to 'origin'\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var parser = new ArgumentParser(\"git-pr sponsor\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n        var head = pr.headHash();\n        var sponsorComment = pr.addComment(\"/sponsor\");\n\n        var seenSponsorComment = false;\n        var expected = \"<!-- Jmerge command reply message (\" + sponsorComment.id() + \") -->\";\n        var pushedPattern = Pattern.compile(\"Pushed as commit ([a-f0-9]{40})\\\\.\");\n        for (var i = 0; i < 90; i++) {\n            var comments = pr.comments();\n            for (var comment : comments) {\n                if (!seenSponsorComment) {\n                    if (comment.id().equals(sponsorComment.id())) {\n                        seenSponsorComment = true;\n                    }\n                    continue;\n                }\n                var lines = comment.body().split(\"\\n\");\n                if (lines.length > 0 && lines[0].equals(expected)) {\n                    for (var line : lines) {\n                        if (pushedPattern.matcher(line).find()) {\n                            var output = removeTrailing(line, \".\");\n                            System.out.println(output);\n                            System.exit(0);\n                        }\n                    }\n                }\n            }\n\n            Thread.sleep(2000);\n        }\n\n        System.err.println(\"error: timed out waiting for response to /sponsor command\");\n        System.exit(1);\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrSummary.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.util.List;\nimport java.nio.file.Files;\n\npublic class GitPrSummary {\n    static final List<Flag> flags = List.of(\n        Switch.shortcut(\"\")\n              .fullname(\"remove\")\n              .helptext(\"Remove an existing summary\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var parser = new ArgumentParser(\"git-pr summary\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n\n        if (arguments.contains(\"remove\")) {\n            showReply(awaitReplyTo(pr, pr.addComment(\"/summary\")));\n            return;\n        }\n\n        var file = Files.createTempFile(\"SUMMARY\", \".txt\");\n        var success = spawnEditor(repo, file);\n        if (!success) {\n            System.err.println(\"error: editor exited with non-zero status code, aborting\");\n            System.exit(1);\n        }\n        var lines = Files.readAllLines(file);\n        if (lines.stream().allMatch(String::isEmpty)) {\n            System.err.println(\"error: no summary present, aborting\");\n            System.exit(1);\n        }\n        var comment = lines.size() == 1 ?\n            pr.addComment(\"/summary \" + lines.get(0)) :\n            pr.addComment(\"/summary\\n\" + String.join(\"\\n\", lines));\n        showReply(awaitReplyTo(pr, comment));\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/GitPrTest.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.cli.ForgeUtils;\n\nimport static org.openjdk.skara.cli.pr.Utils.*;\n\nimport java.io.IOException;\nimport java.util.List;\n\npublic class GitPrTest {\n    static final List<Flag> flags = List.of(\n        Option.shortcut(\"u\")\n              .fullname(\"username\")\n              .describe(\"NAME\")\n              .helptext(\"Username on host\")\n              .optional(),\n        Option.shortcut(\"r\")\n              .fullname(\"remote\")\n              .describe(\"NAME\")\n              .helptext(\"Name of remote, defaults to 'origin'\")\n              .optional(),\n        Option.shortcut(\"j\")\n              .fullname(\"job\")\n              .describe(\"NAME\")\n              .helptext(\"Name of a job\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"approve\")\n              .helptext(\"Approve a test request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"cancel\")\n              .helptext(\"Cancel a test request\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"verbose\")\n              .helptext(\"Turn on verbose output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"debug\")\n              .helptext(\"Turn on debugging output\")\n              .optional(),\n        Switch.shortcut(\"\")\n              .fullname(\"version\")\n              .helptext(\"Print the version of this tool\")\n              .optional()\n    );\n\n    static final List<Input> inputs = List.of(\n        Input.position(0)\n             .describe(\"ID\")\n             .singular()\n             .optional()\n    );\n\n    public static void main(String[] args) throws IOException, InterruptedException {\n        var parser = new ArgumentParser(\"git-pr test\", flags, inputs);\n        var arguments = parse(parser, args);\n        var repo = getRepo();\n        var uri = getURI(repo, arguments);\n        var host = getForge(uri, repo, arguments);\n        var id = pullRequestIdArgument(repo, arguments);\n        var pr = getPullRequest(uri, repo, host, id);\n        var head = pr.headHash();\n\n        var command = \"/test\";\n        if (arguments.contains(\"approve\")) {\n            command += \" approve\";\n        } else if (arguments.contains(\"cancel\")) {\n            command += \" cancel\";\n        } else if (arguments.contains(\"job\")) {\n            command += ForgeUtils.getOption(\"job\", arguments);\n        }\n        var testComment = pr.addComment(command);\n\n        var seenTestComment = false;\n        for (var i = 0; i < 90; i++) {\n            var comments = pr.comments();\n            for (var comment : comments) {\n                if (!seenTestComment) {\n                    if (comment.id().equals(testComment.id())) {\n                        seenTestComment = true;\n                    }\n                    continue;\n                }\n                var lines = comment.body().split(\"\\n\");\n                var n = lines.length;\n                if (n > 0) {\n                    if (n == 4 &&\n                        lines[0].equals(\"<!-- TEST STARTED -->\") &&\n                        lines[1].startsWith(\"<!-- github.com-\") &&\n                        lines[2].equals(\"<!-- \" + head.hex() + \" -->\")) {\n                        var output = removeTrailing(lines[3], \".\");\n                        System.out.println(output);\n                        System.exit(0);\n                    } else if (n == 2 &&\n                               lines[0].equals(\"<!-- TEST ERROR -->\")) {\n                        var output = removeTrailing(lines[1], \".\");\n                        System.out.println(output);\n                        System.exit(1);\n                    } else if (n == 4 &&\n                               lines[0].equals(\"<!-- TEST PENDING -->\") &&\n                               lines[1].equals(\"<!--- \" + head.hex() + \" -->\")) {\n                        var output = removeTrailing(lines[3], \".\");\n                        System.out.println(output);\n                        System.exit(0);\n                    }\n                }\n            }\n\n            Thread.sleep(2000);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/main/java/org/openjdk/skara/cli/pr/Utils.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.pr;\n\nimport org.openjdk.skara.args.*;\nimport org.openjdk.skara.cli.ForgeUtils;\nimport org.openjdk.skara.cli.Logging;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.issuetracker.IssueTracker;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.issuetracker.IssueTrackerIssue;\nimport org.openjdk.skara.jcheck.JCheckConfiguration;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\nimport org.openjdk.skara.version.Version;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.StandardOpenOption;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.logging.Level;\nimport java.util.regex.Matcher;\n\nclass Utils {\n    static final Pattern ISSUE_ID_PATTERN = Pattern.compile(\"([A-Za-z][A-Za-z0-9]+)?-([0-9]+)\");\n    static final Pattern ISSUE_MARKDOWN_PATTERN =\n        Pattern.compile(\"^(?: \\\\* )?\\\\[([A-Z]+-[0-9]+)\\\\]\\\\(https:\\\\/\\\\/bugs.openjdk.(?:java.net)|(?:org)\\\\/browse\\\\/[A-Z]+-[0-9]+\\\\): .*$\");\n\n    static void exit(String fmt, Object...args) {\n        System.err.println(String.format(fmt, args));\n        System.exit(1);\n    }\n\n    static String getOption(String name, String subsection, Arguments arguments) {\n        return ForgeUtils.getOption(name, \"pr\", subsection, arguments);\n    }\n\n    static boolean getSwitch(String name, String subsection, Arguments arguments) {\n        return ForgeUtils.getSwitch(name, \"pr\", subsection, arguments);\n    }\n\n    static String rightPad(String s, int length) {\n        return String.format(\"%-\" + length + \"s\", s);\n    }\n\n    static void appendPaddedHTMLComment(Path file, String line) throws IOException {\n        var end = \" -->\";\n        var pad = 79 - end.length();\n        var newLine = \"\\n\";\n        Files.writeString(file, rightPad(\"<!-- \" + line, pad) + end + newLine, StandardOpenOption.APPEND);\n    }\n\n    static String format(Issue issue) {\n        var parts = issue.id().split(\"-\");\n        var id = parts.length == 2 ? parts[1] : issue.id();\n        return id + \": \" + issue.title();\n    }\n\n    static String pullRequestIdArgument(ReadOnlyRepository repo, Arguments arguments) throws IOException {\n        if (arguments.at(0).isPresent()) {\n            return arguments.at(0).asString();\n        }\n\n        var currentBranch = repo.currentBranch();\n        if (currentBranch.isPresent()) {\n            var lines = repo.config(\"pr.\" + currentBranch.get().name() + \".id\");\n            if (lines.size() == 1) {\n                return lines.get(0);\n            }\n        }\n\n        exit(\"error: you must provide a pull request id\");\n        return null;\n    }\n\n    static String statusForPullRequest(PullRequest pr) {\n        var labels = pr.labelNames();\n        if (pr.isDraft()) {\n            return \"DRAFT\";\n        } else if (labels.contains(\"integrated\")) {\n            return \"INTEGRATED\";\n        } else if (labels.contains(\"ready\")) {\n            return \"READY\";\n        } else if (labels.contains(\"rfr\")) {\n            return \"RFR\";\n        } else if (labels.contains(\"merge-conflict\")) {\n            return \"CONFLICT\";\n        } else if (labels.contains(\"oca\")) {\n            return \"OCA\";\n        } else {\n            var checks = pr.checks(pr.headHash());\n            var jcheck = Optional.ofNullable(checks.get(\"jcheck\"));\n            if (jcheck.isPresent()) {\n                var checkStatus = jcheck.get().status();\n                if (checkStatus == CheckStatus.IN_PROGRESS) {\n                    return \"CHECKING\";\n                } else if (checkStatus == CheckStatus.SUCCESS) {\n                    return \"RFR\";\n                } else if (checkStatus == CheckStatus.FAILURE) {\n                    return \"FAILURE\";\n                }\n            } else {\n                return \"CHECKING\";\n            }\n        }\n\n        return \"UNKNOWN\";\n    }\n\n    static String statusForCheck(Check check) {\n        var checkStatus = check.status();\n        if (checkStatus == CheckStatus.IN_PROGRESS) {\n            return \"RUNNING\";\n        } else if (checkStatus == CheckStatus.SUCCESS) {\n            return \"OK\";\n        } else if (checkStatus == CheckStatus.FAILURE) {\n            return \"FAILED\";\n        } else if (checkStatus == CheckStatus.CANCELLED) {\n            return \"CANCELLED\";\n        } else if (checkStatus == CheckStatus.STALE) {\n            return \"STALE\";\n        }\n\n        return \"UNKNOWN\";\n    }\n\n    static List<String> issuesFromPullRequest(PullRequest pr) {\n        var issueTitleIndex = -1;\n        var lines = pr.body().split(\"\\n\");\n        for (var i = 0; i < lines.length; i++) {\n            if (lines[i].startsWith(\"### Issue\")) {\n                issueTitleIndex = i;\n                break;\n            }\n        }\n\n        if (issueTitleIndex == -1) {\n            return List.of();\n        }\n\n        var issues = new ArrayList<String>();\n        for (var i = issueTitleIndex + 1; i < lines.length; i++) {\n            var m = ISSUE_MARKDOWN_PATTERN.matcher(lines[i]);\n            if (m.matches()) {\n                issues.add(m.group(1));\n            } else {\n                break;\n            }\n        }\n\n        return issues;\n    }\n\n    static Optional<String> jbsProjectFromJcheckConf(Repository repo, String targetBranch) throws IOException {\n        var conf = JCheckConfiguration.from(repo, repo.resolve(targetBranch).orElseThrow(() ->\n            new IOException(\"Could not resolve '\" + targetBranch + \"' branch\")\n        ));\n\n        if (conf.isEmpty()) {\n            return Optional.empty();\n        }\n\n        return Optional.ofNullable(conf.get().general().jbs());\n    }\n\n    static Optional<IssueTrackerIssue> getIssue(Commit commit, Optional<String> project) throws IOException {\n        return project.isEmpty() ? Optional.empty() : getIssue(commit, project.get());\n    }\n\n    static Optional<IssueTrackerIssue> getIssue(Commit commit, String project) throws IOException {\n        var message = CommitMessageParsers.v1.parse(commit.message());\n        var issues = message.issues();\n        if (issues.isEmpty()) {\n            return getIssue(message.title(), project);\n        } else if (issues.size() == 1) {\n            var issue = issues.get(0);\n            return getIssue(issue.id(), project);\n        }\n        return Optional.empty();\n    }\n\n    static Optional<IssueTrackerIssue> getIssue(Branch b, Optional<String> project) throws IOException {\n        return project.isEmpty() ? Optional.empty() : getIssue(b, project.get());\n    }\n\n    static Optional<IssueTrackerIssue> getIssue(Branch b, String project) throws IOException {\n        return getIssue(b.name(), project);\n    }\n\n    static Optional<IssueTrackerIssue> getIssue(String s, String project) throws IOException {\n        var m = ISSUE_ID_PATTERN.matcher(s);\n        if (m.matches()) {\n            var id = m.group(2);\n            if (project == null) {\n                project = m.group(1);\n            }\n            var issueTracker = IssueTracker.from(\"jira\", URI.create(\"https://bugs.openjdk.org\"));\n            return issueTracker.project(project).issue(id);\n        }\n\n        return Optional.empty();\n    }\n\n    static void await(Process p, Integer... allowedExitCodes) throws IOException {\n        var allowed = new HashSet<>(Arrays.asList(allowedExitCodes));\n        allowed.add(0);\n        try {\n            var res = p.waitFor();\n\n            if (!allowed.contains(res)) {\n                throw new IOException(\"Unexpected exit code \" + res);\n            }\n        } catch (InterruptedException e) {\n            throw new IOException(e);\n        }\n    }\n\n    static boolean spawnEditor(ReadOnlyRepository repo, Path file) throws IOException {\n        String editor = null;\n        var lines = repo.config(\"core.editor\");\n        if (lines.size() == 1) {\n            editor = lines.get(0);\n        }\n        if (editor == null) {\n            editor = System.getenv(\"GIT_EDITOR\");\n        }\n        if (editor == null) {\n            editor = System.getenv(\"EDITOR\");\n        }\n        if (editor == null) {\n            editor = System.getenv(\"VISUAL\");\n        }\n        if (editor == null) {\n            editor = \"vi\";\n        }\n\n        // As an editor command may have multiple arguments, we need to add each single one\n        // to the ProcessBuilder. Arguments are split by whitespace and can be quoted.\n        // e.g. I found core.editor =\n        // \\\"C:\\\\\\\\Program Files\\\\\\\\Notepad++\\\\\\\\notepad++.exe\\\" -multiInst -notabbar -nosession -noPlugin\n        List<String> editorParts = new ArrayList<>();\n        Matcher em = Pattern.compile(\"\\\\s*([^\\\"]\\\\S*|\\\".+?\\\")\\\\s*\").matcher(editor);\n        while (em.find()) {\n            editorParts.add(em.group(1));\n        }\n        editorParts.add(file.toString());\n        var pb = new ProcessBuilder(editorParts);\n        pb.inheritIO();\n        var p = pb.start();\n        try {\n            return p.waitFor() == 0;\n        } catch (InterruptedException e) {\n            throw new IOException(e);\n        }\n    }\n\n    static PullRequest getPullRequest(URI uri, ReadOnlyRepository repo, Forge host, String prId) throws IOException {\n        var pr = ForgeUtils.getHostedRepositoryFor(uri, repo, host).pullRequest(prId);\n        if (pr == null) {\n            exit(\"error: could not fetch PR information\");\n        }\n\n        return pr;\n    }\n\n    static void show(String ref, Hash hash, boolean useTool) throws IOException {\n        show(ref, hash, null, useTool);\n    }\n\n    static void show(String ref, Hash hash, Path dir, boolean useTool) throws IOException {\n        var command = useTool ? \"difftool\" : \"diff\";\n        var pb = new ProcessBuilder(\"git\", command, \"--binary\",\n                                                   \"--patch\",\n                                                   \"--find-renames=50%\",\n                                                   \"--find-copies=50%\",\n                                                   \"--find-copies-harder\",\n                                                   \"--abbrev\",\n                                                   ref + \"...\" + hash.hex());\n        if (dir != null) {\n            pb.directory(dir.toFile());\n        }\n        pb.inheritIO();\n\n        // git will return 141 (128 + 13) when it receive SIGPIPE (signal 13) from\n        // e.g. less when a user exits less when looking at a large diff. Therefore\n        // must allow 141 as a valid exit code.\n        await(pb.start(), 141);\n    }\n\n    static Path diff(String ref, Hash hash) throws IOException {\n        return diff(ref, hash, null);\n    }\n\n    static Path diff(String ref, Hash hash, Path dir) throws IOException {\n        var patch = Files.createTempFile(hash.hex(), \".patch\");\n        var pb = new ProcessBuilder(\"git\", \"diff\", \"--binary\",\n                                                   \"--patch\",\n                                                   \"--find-renames=50%\",\n                                                   \"--find-copies=50%\",\n                                                   \"--find-copies-harder\",\n                                                   \"--abbrev\",\n                                                   ref + \"...\" + hash.hex());\n        if (dir != null) {\n            pb.directory(dir.toFile());\n        }\n        pb.redirectOutput(patch.toFile());\n        pb.redirectError(ProcessBuilder.Redirect.INHERIT);\n        await(pb.start());\n        return patch;\n    }\n\n    static void apply(Path patch) throws IOException {\n        var pb = new ProcessBuilder(\"git\", \"apply\", patch.toString());\n        pb.inheritIO();\n        await(pb.start());\n    }\n\n    static String removeTrailing(String s, String trail) {\n        return s.endsWith(trail) ?\n            s.substring(0, s.length() - trail.length()) :\n            s;\n    }\n\n    static Repository getRepo() throws IOException {\n        var cwd = Path.of(\"\").toAbsolutePath();\n        return Repository.get(cwd).orElseThrow(() -> new IOException(\"no git repository found at \" + cwd.toString()));\n    }\n\n    static Arguments parse(ArgumentParser parser, String[] args) {\n        var arguments = parser.parse(args);\n        if (arguments.contains(\"version\")) {\n            System.out.println(\"git-pr version: \" + Version.fromManifest().orElse(\"unknown\"));\n            System.exit(0);\n        }\n        if (arguments.contains(\"verbose\") || arguments.contains(\"debug\")) {\n            var level = arguments.contains(\"debug\") ? Level.FINER : Level.FINE;\n            Logging.setup(level);\n        }\n        return arguments;\n    }\n\n    static String getRemote(ReadOnlyRepository repo, Arguments arguments) throws IOException {\n        return ForgeUtils.getRemote(repo, \"pr\", arguments);\n    }\n\n    static URI getURI(ReadOnlyRepository repo, Arguments arguments) throws IOException {\n        return ForgeUtils.getURI(repo, \"pr\", arguments);\n    }\n\n    static Forge getForge(URI uri, ReadOnlyRepository repo, Arguments arguments) throws IOException {\n        return ForgeUtils.getForge(uri, repo, \"pr\", arguments);\n    }\n\n    public static Optional<Comment> awaitReplyTo(PullRequest pr, Comment command) throws InterruptedException {\n        for (var i = 0; i < 90; i++) {\n            for (var comment : pr.comments()) {\n                if (comment.body().startsWith(\"<!-- Jmerge command reply message (\" + command.id()  + \") -->\\n\")) {\n                    return Optional.of(comment);\n                }\n            }\n            Thread.sleep(2000);\n        }\n\n        return Optional.empty();\n    }\n\n    public static void showReply(Optional<Comment> reply) {\n        if (reply.isEmpty()) {\n            System.err.println(\"error: timed out while waiting for reply\");\n            System.exit(1);\n        }\n\n        var lines = Arrays.asList(reply.get().body().split(\"\\n\"));\n        for (var line : lines) {\n            if (line.startsWith(\"<!--\") && line.endsWith(\"-->\")) {\n                continue;\n            }\n\n            System.out.println(line);\n        }\n    }\n}\n"
  },
  {
    "path": "cli/src/test/java/org/openjdk/skara/cli/debug/TestGitMlRules.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.cli.debug;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\npublic class TestGitMlRules {\n    @Test\n    void collapseEquals() {\n        assertEquals(Map.of(\"\", List.of(\"v1\")),\n                     GitMlRules.stripDuplicatePrefixes(Map.of(\"k1\", List.of(\"v1\"))));\n    }\n\n    @Test\n    void collapseSameList() {\n        assertEquals(Map.of(\"\", List.of(\"v1\")),\n                     GitMlRules.stripDuplicatePrefixes(Map.of(\"k1a\", List.of(\"v1\"),\n                                                              \"k1b\", List.of(\"v1\"))));\n    }\n\n    @Test\n    void collapseDifferentList() {\n        assertEquals(Map.of(\"k1a\", List.of(\"v1\"),\n                            \"k1b\", List.of(\"v2\")),\n                     GitMlRules.stripDuplicatePrefixes(Map.of(\"k1a\", List.of(\"v1\"),\n                                                              \"k1b\", List.of(\"v2\"))));\n    }\n\n    @Test\n    void collapseMultiple() {\n        assertEquals(Map.of(\"\", List.of(\"v1\")),\n                     GitMlRules.stripDuplicatePrefixes(Map.of(\"k1a\", List.of(\"v1\"),\n                                                              \"k1b\", List.of(\"v1\"),\n                                                              \"k2bb\", List.of(\"v1\"))));\n\n    }\n\n    @Test\n    void collapseMultiple2() {\n        assertEquals(Map.of(\"\", List.of(\"v1\")),\n                     GitMlRules.stripDuplicatePrefixes(Map.of(\"k1a\", List.of(\"v1\"),\n                                                              \"k1b\", List.of(\"v1\"),\n                                                              \"k2bb\", List.of(\"v1\"),\n                                                              \"k4\", List.of(\"v1\"))));\n\n    }\n\n    @Test\n    void collapseSingle() {\n        assertEquals(Map.of(\"k1/a\", List.of(\"v1\"),\n                            \"k1/b\", List.of(\"v2\")),\n                     GitMlRules.stripDuplicatePrefixes(Map.of(\"k1/a/a\", List.of(\"v1\"),\n                                                              \"k1/b/b\", List.of(\"v2\"))));\n\n    }\n\n    @Test\n    void collapseSingle2() {\n        assertEquals(Map.of(\"k/1\", List.of(\"v1\"),\n                            \"k/2a\", List.of(\"v2\")),\n                     GitMlRules.stripDuplicatePrefixes(Map.of(\"k/1/aa\", List.of(\"v1\"),\n                                                              \"k/1/bb\", List.of(\"v1\"),\n                                                              \"k/2a\", List.of(\"v2\"))));\n\n    }\n\n}\n"
  },
  {
    "path": "config/mailinglist/rules/jdk.json",
    "content": "{\n    \"matchers\": {\n        \"build\": [\n            \"\\\\.\\\\w+\",\n            \"makefile|configure\",\n            \"bin/\",\n            \"doc/\",\n            \"make/\",\n            \"src/utils/hsdis/\",\n            \"src/utils/src/\",\n            \"test/jdk/build/\",\n            \"test/make/\"\n        ],\n        \"client\": [\n            \"make/\\\\w+(Demos|X11)\",\n            \"make/autoconf/lib-(alsa|cups|font|freetype|x11)\",\n            \"make/jdk/src/classes/build/tools/(generatenimbus|x11wrappergen)\",\n            \"make/modules/java.desktop/\",\n            \"make/modules/java.datatransfer/\",\n            \"make/modules/jdk.accessibility/\",\n            \"make/modules/jdk.unsupported.desktop/\",\n            \"src/demo/\",\n            \"src/java.base/share/classes/jdk/internal/access/\\\\w+AWT\",\n            \"src/java.datatransfer/\",\n            \"src/java.desktop/\",\n            \"src/jdk.accessibility/\",\n            \"src/jdk.unsupported.desktop/\",\n            \"test/jdk/com/apple/laf/\",\n            \"test/jdk/com/apple/eawt/\",\n            \"test/jdk/com/sun/java/\",\n            \"test/jdk/demo/jfc/\",\n            \"test/jdk/java/awt/\",\n            \"test/jdk/java/beans/\",\n            \"test/jdk/javax/accessibility/\",\n            \"test/jdk/javax/imageio/\",\n            \"test/jdk/javax/print/\",\n            \"test/jdk/javax/sound/\",\n            \"test/jdk/javax/swing/\",\n            \"test/jdk/lib/client/\",\n            \"test/jdk/performance/client/\",\n            \"test/jdk/sanity/client/\",\n            \"test/jdk/sun/awt/\",\n            \"test/jdk/sun/java2d/\"\n        ],\n        \"compiler\": [\n            \"make/jdk/src/classes/build/tools/depend\",\n            \"make/jdk/src/classes/build/tools/taglet/Preview.java\",\n            \"make/langtools/\",\n            \"make/modules/jdk.compiler/\",\n            \"make/modules/jdk.jshell\",\n            \"make/scripts/generate-symbol-data.sh\",\n            \"src/java.base/share/classes/jdk/internal/javac/\",\n            \"src/java.compiler/\",\n            \"src/jdk.compiler/\",\n            \"src/jdk.editpad/\",\n            \"src/jdk.internal.ed/\",\n            \"src/jdk.internal.le/\",\n            \"src/jdk.jartool/(?!.*/jarsigner)\",\n            \"src/jdk.jdeps/share/classes/com/sun/tools/classfile/\",\n            \"src/jdk.jdeps/share/classes/com/sun/tools/javap/\",\n            \"src/jdk.jdeps/share/classes/com/sun/tools/jdeprscan/\",\n            \"src/jdk.jshell/\",\n            \"test/langtools/TEST.ROOT\",\n            \"test/langtools/TEST.groups\",\n            \"test/langtools/ProblemList-graal.txt\",\n            \"test/langtools/ProblemList.txt\",\n            \"test/langtools/req.flg\",\n            \"test/langtools/lib/combo/TEST.properties\",\n            \"test/langtools/jdk/internal/shellsupport/\",\n            \"test/langtools/jdk/jshell/\",\n            \"test/langtools/lib/annotations\",\n            \"test/langtools/lib/combo/tools/javac/\",\n            \"test/langtools/tools/\"\n        ],\n        \"core-libs\": [\n            \"bin/idea.sh\",\n            \"make/data/cldr/\",\n            \"make/jdk/src/classes/build/tools/blacklistedcertsconverter\",\n            \"make/jdk/src/classes/build/tools/classlist\",\n            \"make/jdk/src/classes/build/tools/cldrconverter/\",\n            \"make/jdk/src/classes/build/tools/dtdbuilder/\",\n            \"make/jdk/src/classes/build/tools/generatebreakiteratordata/\",\n            \"make/jdk/src/classes/build/tools/generatelsrequivmaps\",\n            \"make/jdk/src/classes/build/tools/jigsaw/\",\n            \"make/jdk/src/classes/build/tools/spp\",\n            \"make/modules/java.base/\",\n            \"make/modules/java.prefs\",\n            \"src/java.base/aix/native/libjli\",\n            \"src/java.base/aix/native/libjava/\",\n            \"src/java.base/linux/classes/jdk\",\n            \"src/java.base/linux/native/libjava/\",\n            \"src/java.base/macosx/classes/jdk\",\n            \"src/java.base/macosx/classes/sun/util\",\n            \"src/java.base/macosx/native/libjava/\",\n            \"src/java.base/macosx/native/libjli\",\n            \"src/java.base/share/classes/module-info.java\",\n            \"src/java.base/share/classes/java/io/\",\n            \"src/java.base/share/classes/java/lang/\",\n            \"src/java.base/share/classes/java/math/\",\n            \"src/java.base/share/classes/java/text/\",\n            \"src/java.base/share/classes/java/time/\",\n            \"src/java.base/share/classes/java/util/\",\n            \"src/java.base/share/classes/jdk/internal/(?!icu)\",\n            \"src/java.base/share/classes/sun/invoke/util/\",\n            \"src/java.base/share/classes/sun/launcher/\",\n            \"src/java.base/share/classes/sun/reflect/\",\n            \"src/java.base/share/classes/sun/text/\",\n            \"src/java.base/share/classes/sun/util/BuddhistCalendar.java\",\n            \"src/java.base/share/classes/sun/util/PropertyResourceBundleCharset.java\",\n            \"src/java.base/share/classes/sun/util/calendar/\",\n            \"src/java.base/share/classes/sun/util/cldr\",\n            \"src/java.base/share/classes/sun/util/locale/\",\n            \"src/java.base/share/classes/sun/util/resources/\",\n            \"src/java.base/share/data/lsrdata/\",\n            \"src/java.base/share/legal/\",\n            \"src/java.base/share/lib\",\n            \"src/java.base/share/native/include/classfile_constants.h.template\",\n            \"src/java.base/share/native/include/jni.h\",\n            \"src/java.base/share/native/launcher/main.c\",\n            \"src/java.base/share/native/libfdlibm/\",\n            \"src/java.base/share/native/libjava/\",\n            \"src/java.base/share/native/libjli/\",\n            \"src/java.base/share/native/libverify/\",\n            \"src/java.base/share/native/libzip/\",\n            \"src/java.base/unix/classes/java/io/\",\n            \"src/java.base/unix/classes/java/lang/\",\n            \"src/java.base/unix/classes/jdk/internal/loader/\",\n            \"src/java.base/unix/native/include\",\n            \"src/java.base/unix/native/jspawnhelper\",\n            \"src/java.base/unix/native/libjava/\",\n            \"src/java.base/unix/native/libjli/\",\n            \"src/java.base/unix/native/libjsig\",\n            \"src/java.base/windows/classes/java/io/\",\n            \"src/java.base/windows/classes/java/lang/\",\n            \"src/java.base/windows/classes/jdk/internal/loader/\",\n            \"src/java.base/windows/classes/sun/util\",\n            \"src/java.base/windows/native/common\",\n            \"src/java.base/windows/native/include\",\n            \"src/java.base/windows/native/libjava/\",\n            \"src/java.base/windows/native/libjimage\",\n            \"src/java.base/windows/native/libjli/\",\n            \"src/java.logging/\",\n            \"src/java.naming/\",\n            \"src/java.prefs/\",\n            \"src/java.rmi/\",\n            \"src/java.se/\",\n            \"src/java.sql\",\n            \"src/java.transaction.xa/\",\n            \"src/java.xml/\",\n            \"src/jdk.incubator.foreign/\",\n            \"src/jdk.incubator.vector/\",\n            \"src/jdk.jpackage/\",\n            \"src/jdk.internal.opt/\",\n            \"src/jdk.jartool/(?!.*/jarsigner)\",\n            \"src/jdk.jdeps/\",\n            \"src/jdk.jlink/\",\n            \"src/jdk.jstatd/\",\n            \"src/jdk.naming.dns/\",\n            \"src/jdk.naming.rmi/\",\n            \"src/jdk.nio.mapmode/\",\n            \"src/jdk.random/\",\n            \"src/jdk.unsupported/\",\n            \"src/jdk.xml.dom/\",\n            \"src/jdk.zipfs/\",\n            \"test/failure_handler/\",\n            \"test/jaxp/\",\n            \"test/jdk/TEST.ROOT\",\n            \"test/jdk/TEST.groups\",\n            \"test/jdk/com/sun/jndi/\",\n            \"test/jdk/com/sun/tools/attach/\",\n            \"test/jdk/java/foreign/\",\n            \"test/jdk/java/io/\",\n            \"test/jdk/java/lang/\",\n            \"test/jdk/java/math/\",\n            \"test/jdk/java/rmi/\",\n            \"test/jdk/java/sql/\",\n            \"test/jdk/java/text/\",\n            \"test/jdk/java/time/\",\n            \"test/jdk/java/util/\",\n            \"test/jdk/javax/naming/\",\n            \"test/jdk/javax/rmi/\",\n            \"test/jdk/javax/script/\",\n            \"test/jdk/javax/sql/\",\n            \"test/jdk/javax/transaction\",\n            \"test/jdk/javax/xml/jaxp/\",\n            \"test/jdk/jdk/internal/\",\n            \"test/jdk/jdk/lambda/\",\n            \"test/jdk/jdk/modules/\",\n            \"test/jdk/lib/testlibrary/bootlib/java.base/\",\n            \"test/jdk/lib/testlibrary/bytecode\",\n            \"test/jdk/lib/testlibrary/java/lang\",\n            \"test/jdk/lib/testlibrary/java/util/\",\n            \"test/jdk/sun/misc/\",\n            \"test/jdk/sun/reflect/\",\n            \"test/jdk/sun/rmi/\",\n            \"test/jdk/sun/text/\",\n            \"test/jdk/sun/tools/\",\n            \"test/jdk/sun/util/calendar/\",\n            \"test/jdk/sun/util/locale/\",\n            \"test/jdk/sun/util/logging\",\n            \"test/jdk/sun/util/resources/cldr/\",\n            \"test/jdk/tools/jar/\",\n            \"test/jdk/tools/jimage/\",\n            \"test/jdk/tools/jlink/\",\n            \"test/jdk/tools/jmod/\",\n            \"test/jdk/tools/jpackage/\",\n            \"test/jdk/tools/launcher/\",\n            \"test/jdk/tools/lib/tests/Helper.java\",\n            \"test/lib-test/ProblemList.txt\",\n            \"test/lib-test/RedefineClassTest.java\",\n            \"test/lib-test/TEST.ROOT\",\n            \"test/lib-test/TEST.groups\",\n            \"test/lib-test/jdk/test/lib/\",\n            \"test/lib/ClassFileInstaller.java\",\n            \"test/lib/jdk/test/lib/Asserts.java\",\n            \"test/lib/jdk/test/lib/Container.java\",\n            \"test/lib/jdk/test/lib/FileInstaller.java\",\n            \"test/lib/jdk/test/lib/JDKToolLauncher.java\",\n            \"test/lib/jdk/test/lib/LockFreeLogger.java\",\n            \"test/lib/jdk/test/lib/NetworkConfiguration.java\",\n            \"test/lib/jdk/test/lib/\\\\w+.java\",\n            \"test/lib/jdk/test/lib/cli/\",\n            \"test/lib/jdk/test/lib/dcmd/\",\n            \"test/lib/jdk/test/lib/hexdump/\",\n            \"test/lib/jdk/test/lib/process/\",\n            \"test/lib/jdk/test/lib/util/(?!core)\",\n            \"test/micro/org/openjdk/bench/java/io/\",\n            \"test/micro/org/openjdk/bench/java/lang/\",\n            \"test/micro/org/openjdk/bench/java/math/\",\n            \"test/micro/org/openjdk/bench/java/util/\",\n            \"test/micro/org/openjdk/bench/jdk\",\n            \"test/micro/org/openjdk/bench/vm/lang/\"\n        ],\n        \"hotspot\": [\n            \"doc/hotspot-.*\",\n            \"src/hotspot/share/cppstdlib/\",\n            \"src/hotspot/share/metaprogramming/\",\n            \"src/hotspot/share/precompiled/\",\n            \"src/hotspot/share/prims/\",\n            \"src/hotspot/share/utilities/\",\n            \"test/hotspot/gtest/utilities/\",\n            \"test/hotspot/jtreg/TEST.ROOT\",\n            \"test/hotspot/jtreg/TEST.groups\",\n            \"test/hotspot/jtreg/TEST.quick-groups\",\n            \"test/hotspot/jtreg/vmTestbase/vm/share/\"\n        ],\n        \"hotspot-compiler\": [\n            \"src/hotspot/.mx.jvmci/\",\n            \"src/hotspot/cpu/\",\n            \"src/hotspot/share/adlc/\",\n            \"src/hotspot/share/aot/\",\n            \"src/hotspot/share/asm/\",\n            \"src/hotspot/share/c1/\",\n            \"src/hotspot/share/ci/\",\n            \"src/hotspot/share/classfile/vmSymbols\",\n            \"src/hotspot/share/code/\",\n            \"src/hotspot/share/compiler/\",\n            \"src/hotspot/share/gc/g1/c1\",\n            \"src/hotspot/share/gc/g1/c2/\",\n            \"src/hotspot/share/gc/shared/.*barrierset\",\n            \"src/hotspot/share/jvmci/\",\n            \"src/hotspot/share/libadt/\",\n            \"src/hotspot/share/opto/\",\n            \"src/hotspot/share/runtime/(deoptimization|frame|icache|registermap|sharedruntime|stub|sweeper|vframe)\",\n            \"src/jdk.aot/\",\n            \"src/jdk.internal.vm.ci/\",\n            \"src/jdk.internal.vm.compiler.management/\",\n            \"src/jdk.internal.vm.compiler/\",\n            \"src/utils/IdealGraphVisualizer/\",\n            \"src/utils/LogCompilation/\",\n            \"src/utils/hsdis/\",\n            \"test/hotspot/gtest/code\",\n            \"test/hotspot/gtest/compiler\",\n            \"test/hotspot/gtest/opto\",\n            \"test/hotspot/jtreg/compiler/\",\n            \"test/hotspot/jtreg/testlibrary/ctw/\",\n            \"test/hotspot/jtreg/testlibrary/jittester/\",\n            \"test/hotspot/jtreg/testlibrary_tests/compile_framework/\",\n            \"test/hotspot/jtreg/testlibrary_tests/generators/\",\n            \"test/hotspot/jtreg/testlibrary_tests/ir_framework/\",\n            \"test/hotspot/jtreg/testlibrary_tests/verify/\",\n            \"test/hotspot/jtreg/vmTestbase/jit/\",\n            \"test/hotspot/jtreg/vmTestbase/vm/compiler/\",\n            \"test/hotspot/jtreg/vmTestbase/vm/jit/\",\n            \"test/hotspot/jtreg/vmTestbase/vm/mlvm/meth/\",\n            \"test/hotspot/jtreg/vmTestbase/vm/mlvm/mixed\",\n            \"test/hotspot/jtreg/vmTestbase/vm/mlvm/patches/java.base/java\",\n            \"test/hotspot/jtreg/vmTestbase/vm/mlvm/patches/java.base/jdk\",\n            \"test/hotspot/jtreg/vmTestbase/vm/mlvm/share/\",\n            \"test/hotspot/jtreg/vmTestbase/vm/mlvm/tools\",\n            \"test/lib/sun/hotspot/code/\",\n            \"test/lib/sun/hotspot/cpuinfo\",\n            \"test/micro/org/openjdk/bench/vm/compiler/\"\n        ],\n        \"hotspot-gc\": [\n            \"src/hotspot/cpu/\\\\w+/gc/\",\n            \"src/hotspot/os/\\\\w+/gc/\",\n            \"src/hotspot/os_cpu/\\\\w+/gc/\",\n            \"src/hotspot/share/gc/\",\n            \"src/hotspot/share/memory/oop\",\n            \"src/hotspot/share/oops/\",\n            \"src/hotspot/share/services/gc\",\n            \"src/jdk.hotspot.agent/share/classes/sun/jvm/hotspot/gc/\",\n            \"src/jdk.hotspot.agent/share/classes/sun/jvm/hotspot/memory/\",\n            \"src/jdk.hotspot.agent/share/classes/sun/jvm/hotspot/oops/\",\n            \"src/jdk.hotspot.agent/share/classes/sun/jvm/hotspot/tools/HeapSummary.java\",\n            \"test/hotspot/gtest/gc/\",\n            \"test/hotspot/gtest/memory/\",\n            \"test/hotspot/jtreg/gc/\",\n            \"test/hotspot/jtreg/runtime/CheckUnhandledOops/TestVerifyOops.java\",\n            \"test/hotspot/jtreg/runtime/CompressedOops/UseCompressedOops.java\",\n            \"test/hotspot/jtreg/vmTestbase/gc/\",\n            \"test/hotspot/jtreg/vmTestbase/metaspace/\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/share/gc/\",\n            \"test/hotspot/jtreg/vmTestbase/vm/gc\",\n            \"test/hotspot/jtreg/vmTestbase/vm/share/gc/\",\n            \"test/jdk/jdk/jfr/event/gc/\",\n            \"test/lib/sun/hotspot/gc\"\n        ],\n        \"hotspot-jfr\": [\n            \"make/modules/jdk.jfr/\",\n            \"src/hotspot/share/jfr/\",\n            \"src/jdk.internal.vm.ci/share/classes/jdk.vm.ci.hotspot/src/jdk/vm/ci/hotspot/JFR.java\",\n            \"src/jdk.jfr/\",\n            \"src/jdk.management.jfr/\",\n            \"test/hotspot/gtest/jfr/\",\n            \"test/hotspot/jtreg/containers/docker/.*jfr\",\n            \"test/hotspot/jtreg/gc/stress/jfr\",\n            \"test/jdk/jdk/jfr/\",\n            \"test/lib/jdk/test/lib/jfr/\"\n        ],\n        \"hotspot-runtime\": [\n            \"make/data/hotspot-symbols/\",\n            \"src/hotspot/cpu/\\\\w+/(\\\\w*abstractInt|assembler|bytes|compressed|continuation|copy|global|interp|frame|jni|macroassembler|rdtsc|register|sharedRuntime|small|template|upcall|vm_version|vmStruct)\",\n            \"src/hotspot/os/\",\n            \"src/hotspot/os_cpu/\",\n            \"src/hotspot/share/cds/\",\n            \"src/hotspot/share/classfile/\",\n            \"src/hotspot/share/include/\",\n            \"src/hotspot/share/include/jvm.h\",\n            \"src/hotspot/share/interpreter/\",\n            \"src/hotspot/share/libadt/\",\n            \"src/hotspot/share/logging/\",\n            \"src/hotspot/share/memory/\",\n            \"src/hotspot/share/nmt/\",\n            \"src/hotspot/share/oops/\",\n            \"src/hotspot/share/prims/(cds|jni|method|native|perf|resolved|stackwalk|unsafe).*\",\n            \"src/hotspot/share/runtime/\",\n            \"src/hotspot/share/sanitizers/\",\n            \"src/hotspot/share/services/(?!gc)\",\n            \"test/hotspot/gtest/classfile/\",\n            \"test/hotspot/gtest/\\\\w+(.cpp|.hpp)\",\n            \"test/hotspot/gtest/logging/\",\n            \"test/hotspot/gtest/memory/\",\n            \"test/hotspot/gtest/oops/\",\n            \"test/hotspot/gtest/runtime/\",\n            \"test/hotspot/jtreg/containers/\",\n            \"test/hotspot/jtreg/native_sanity/\",\n            \"test/hotspot/jtreg/runtime/\",\n            \"test/hotspot/jtreg/vmTestbase/metaspace/\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/share/(\\\\w+.java|sysdict|classload|locks|log|native)\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/stress/except/\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/stress/jni/\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/stress/stack/\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/sysdict\",\n            \"test/hotspot/jtreg/vmTestbase/vm/mlvm/anonloader/\",\n            \"test/hotspot/jtreg/vmTestbase/vm/runtime/\",\n            \"test/jdk/tools/launcher/\",\n            \"test/lib/jdk/test/lib/OSVersion.java\",\n            \"test/lib/jdk/test/lib/Platform.java\",\n            \"test/lib/jdk/test/lib/Utils.java\",\n            \"test/lib/jdk/test/lib/cds/\",\n            \"test/lib/jdk/test/lib/classloader/\",\n            \"test/lib/jdk/test/lib/containers/\",\n            \"test/lib/jdk/test/lib/dcmd/\",\n            \"test/lib/jdk/test/lib/hprof/\",\n            \"test/lib/jdk/test/lib/process/\",\n            \"test/lib/sun/hotspot/WhiteBox.java\"\n        ],\n        \"i18n\": [\n            \"make/data/charsetmapping/\",\n            \"make/data/cldr/\",\n            \"make/jdk/src/classes/build/tools/cldrconverter/\",\n            \"make/jdk/src/classes/build/tools/generatecharacter/\",\n            \"make/jdk/src/classes/build/tools/generateemojidata\",\n            \"make/jdk/src/classes/build/tools/generatelsrequivmaps\",\n            \"make/jdk/src/classes/build/tools/tzdb\",\n            \"make/modules/java.base/gensrc/Gensrc(char|emoji|locale)\",\n            \"src/java.base/macosx/native/libjava/\\\\w*local\",\n            \"src/java.base/share/classes/java/lang/\\\\w*Character\",\n            \"src/java.base/share/classes/java/text/\\\\w*(Bidi|format|collation|normalizer)\",\n            \"src/java.base/share/classes/java/time/chrono/\",\n            \"src/java.base/share/classes/java/time/format/\",\n            \"src/java.base/share/classes/java/time/temporal/\",\n            \"src/java.base/share/classes/java/time/zone/\",\n            \"src/java.base/share/classes/java/util/\\\\w*(calendar|currency|formatter|locale|resource)\",\n            \"src/java.base/share/classes/java/util/regex/\\\\w*(emoji|grapheme)\",\n            \"src/java.base/share/classes/java/util/spi/\\\\w*(name|resource)BundleProvider.java\",\n            \"src/java.base/share/classes/jdk/internal/icu/\",\n            \"src/java.base/share/classes/sun/text/\\\\w*(collator|composed|normalizer)\",\n            \"src/java.base/share/classes/sun/text/resources/FormatData.java\",\n            \"src/java.base/share/classes/sun/text/resources/JavaTimeSupplementary.java\",\n            \"src/java.base/share/classes/sun/util/calendar/\",\n            \"src/java.base/share/classes/sun/util/cldr\",\n            \"src/java.base/share/classes/sun/util/locale/\",\n            \"src/java.base/share/classes/sun/util/resources/\\\\w*(names|locale)\",\n            \"src/java.base/share/data/currency/\",\n            \"src/java.base/share/data/lsrdata/\",\n            \"src/java.base/share/data/tzdata/\",\n            \"src/java.base/share/data/unicodedata/\",\n            \"src/java.base/share/legal/cldr.md\",\n            \"src/java.base/share/legal/icu.md\",\n            \"src/java.base/share/legal/unicode.md\",\n            \"src/java.base/windows/classes/sun/util\",\n            \"src/java.base/windows/native/libjava/\\\\w*(locale|timezone)\",\n            \"src/java.desktop/share/classes/java/awt/(component|toolkit|window|im/)\",\n            \"src/java.desktop/share/classes/sun/awt/im/\",\n            \"src/java.desktop/share/classes/sun/text/\",\n            \"src/java.desktop/windows/classes/sun/awt/windows/winput\",\n            \"src/java.desktop/windows/native/libawt/windows/awt_inputmethod\",\n            \"src/jdk.charsets/\",\n            \"src/jdk.localedata/\",\n            \"test/jdk/java/lang/Character/\",\n            \"test/jdk/java/lang/String/CompactString/\\\\w*(ignore|region)\",\n            \"test/jdk/java/lang/String/\\\\w*casing\",\n            \"test/jdk/java/nio/charset/Charset/RegisteredCharsets.java\",\n            \"test/jdk/java/text/Bidi/\",\n            \"test/jdk/java/text/Collator/\",\n            \"test/jdk/java/text/Format/CompactNumberFormat\",\n            \"test/jdk/java/text/Format/DateFormat/\",\n            \"test/jdk/java/text/Format/DecimalFormat/\",\n            \"test/jdk/java/text/Format/MessageFormat/\",\n            \"test/jdk/java/text/Format/NumberFormat/\",\n            \"test/jdk/java/text/Normalizer/\",\n            \"test/jdk/java/time/nontestng/java/time/chrono/\",\n            \"test/jdk/java/time/nontestng/java/time/zone\",\n            \"test/jdk/java/time/tck/java/time/chrono/\",\n            \"test/jdk/java/time/tck/java/time/format/\",\n            \"test/jdk/java/time/tck/java/time/zone/\",\n            \"test/jdk/java/time/test/java/time/chrono/\",\n            \"test/jdk/java/time/test/java/time/format/\",\n            \"test/jdk/java/time/test/java/time/zone/\",\n            \"test/jdk/java/util/Calendar/\",\n            \"test/jdk/java/util/Currency/\",\n            \"test/jdk/java/util/Formatter/\",\n            \"test/jdk/java/util/Locale/\",\n            \"test/jdk/java/util/PluggableLocale/\",\n            \"test/jdk/java/util/Scanner/spi\",\n            \"test/jdk/java/util/TimeZone/\",\n            \"test/jdk/java/util/regex/\\\\w*grapheme\",\n            \"test/jdk/java/util/spi/ResourceBundleControlProvider/\",\n            \"test/jdk/lib/testlibrary/java/lang\",\n            \"test/jdk/sun/net/idn/\",\n            \"test/jdk/sun/nio/cs/\",\n            \"test/jdk/sun/text/resources/\",\n            \"test/jdk/sun/util/calendar/zi/\",\n            \"test/jdk/sun/util/locale/\",\n            \"test/jdk/sun/util/resources/Calendar/\",\n            \"test/jdk/sun/util/resources/Locale/\",\n            \"test/jdk/sun/util/resources/TimeZone/\",\n            \"test/jdk/sun/util/resources/cldr/\",\n            \"test/jdk/tools/jlink/plugins/\\\\w*locales\"\n        ],\n        \"ide-support\": [\n            \"bin/idea.sh\",\n            \"make/ide/\"\n        ],\n        \"javadoc\": [\n            \"make/data/docs-resources/\",\n            \"make/jdk/src/classes/build/tools/taglet/\",\n            \"src/java.base/share/classes/java/lang/doc-files/\",\n            \"src/jdk.compiler/share/classes/com/sun/source/doctree/\",\n            \"src/jdk.compiler/share/classes/com/sun/source/util/\\\\w*doctree\",\n            \"src/jdk.compiler/share/classes/com/sun/tools/doclint/\",\n            \"src/jdk.compiler/share/classes/com/sun/tools/javac/parser/\\\\w*doccomment\",\n            \"src/jdk.compiler/share/classes/com/sun/tools/javac/tree/\\\\w*(DC|Doc)\",\n            \"src/jdk.compiler/share/classes/jdk/internal/shellsupport/doc/\",\n            \"src/jdk.javadoc/\",\n            \"test/langtools/jdk/internal/shellsupport/doc/JavadocHelperTest.java\",\n            \"test/langtools/jdk/javadoc/\",\n            \"test/langtools/lib/annotations\",\n            \"test/langtools/tools/doclint/\",\n            \"test/langtools/tools/javac/doclint/\",\n            \"test/langtools/tools/javac/doctree/\"\n        ],\n        \"jdk\": [\n            \"(?!makefile|configure)\\\\w+$\"\n        ],\n        \"net\": [\n            \"make/modules/jdk.net\",\n            \"make/modules/jdk.sctp\",\n            \"src/java.base/aix/native/libnet\",\n            \"src/java.base/linux/native/libnet\",\n            \"src/java.base/share/classes/java/net/\",\n            \"src/java.base/share/classes/javax/net\",\n            \"src/java.base/share/classes/sun/net/\",\n            \"src/java.base/share/classes/sun/net/www/\",\n            \"src/java.base/share/conf/net.properties\",\n            \"src/java.base/share/native/libnet/\",\n            \"src/java.base/unix/classes/java/net/\",\n            \"src/java.base/unix/classes/sun/net/\",\n            \"src/java.base/unix/native/libnet/\",\n            \"src/java.base/windows/classes/java/net/\",\n            \"src/java.base/windows/classes/sun/net/\",\n            \"src/java.base/windows/native/libnet/\",\n            \"src/java.net.http/\",\n            \"src/jdk.httpserver/\",\n            \"src/jdk.jdi/share/classes/com/sun/tools/jdi/\\\\w*socket\",\n            \"src/jdk.jfr/share/classes/jdk/jfr/internal/instrument/\\\\w*socket\",\n            \"src/jdk.net/\",\n            \"src/jdk.sctp/\",\n            \"test/jdk/com/sun/net/\",\n            \"test/jdk/java/net/\",\n            \"test/jdk/jdk/net/\",\n            \"test/jdk/sun/net/\",\n            \"test/lib/jdk/test/lib/\\\\w*network\",\n            \"test/lib/jdk/test/lib/net/\",\n            \"test/micro/org/openjdk/bench/java/net/\"\n        ],\n        \"nio\": [\n            \"src/java.base/\\\\w+/classes/java/nio/\",\n            \"src/java.base/\\\\w+/classes/sun/nio/\",\n            \"src/java.base/\\\\w+/native/libnio/\",\n            \"src/jdk.nio.mapmode/\",\n            \"src/jdk.zipfs/share/classes/jdk/nio/\",\n            \"test/jdk/com/sun/nio/\",\n            \"test/jdk/java/nio/\",\n            \"test/jdk/jdk/nio/\",\n            \"test/micro/org/openjdk/bench/java/nio/\"\n        ],\n        \"security\": [\n            \"make/jdk/src/classes/build/tools/generatecacerts\",\n            \"make/jdk/src/classes/build/tools/intpoly\",\n            \"make/jdk/src/classes/build/tools/publicsuffixlist\",\n            \"src/java.base/macosx/classes/apple/security/\",\n            \"src/java.base/macosx/native/libosxsecurity\",\n            \"src/java.base/share/classes/com/sun/crypto/\",\n            \"src/java.base/share/classes/com/sun/security/\",\n            \"src/java.base/share/classes/java/io/FilePermission.java\",\n            \"src/java.base/share/classes/java/lang/SecurityManager.java\",\n            \"src/java.base/share/classes/java/security/\",\n            \"src/java.base/share/classes/java/util/jar/\",\n            \"src/java.base/share/classes/javax/crypto/\",\n            \"src/java.base/share/classes/javax/net/ssl/\",\n            \"src/java.base/share/classes/javax/security/\",\n            \"src/java.base/share/classes/jdk/internal/access/\",\n            \"src/java.base/share/classes/jdk/internal/event/\",\n            \"src/java.base/share/classes/sun/security/\",\n            \"src/java.base/share/conf/security/\",\n            \"src/java.base/share/data/cacerts/\",\n            \"src/java.base/share/data/publicsuffixlist/\",\n            \"src/java.base/share/legal/public_suffix.md\",\n            \"src/java.base/share/native/libjava/\\\\w*(access|security)\",\n            \"src/java.base/unix/classes/sun/net/sdp\",\n            \"src/java.base/unix/classes/sun/security/\",\n            \"src/java.naming/share/classes/sun/security/provider/certpath/\",\n            \"src/java.net.http/share/classes/jdk/internal/net/http/common/\\\\w*ssl\",\n            \"src/java.security.jgss/\",\n            \"src/java.security.sasl/\",\n            \"src/java.smartcardio/\",\n            \"src/java.xml.crypto/\",\n            \"src/jdk.crypto.cryptoki/\",\n            \"src/jdk.crypto.ec/\",\n            \"src/jdk.crypto.mscapi/\",\n            \"src/jdk.jartool/share/classes/com/sun/jarsigner\",\n            \"src/jdk.jartool/share/classes/jdk/security/\",\n            \"src/jdk.jartool/share/classes/sun/security/\",\n            \"src/jdk.jartool/share/man/jarsigner.1\",\n            \"src/jdk.sctp/unix/classes/sun/nio/ch/sctp/SctpNet.java\",\n            \"src/jdk.sctp/unix/native\",\n            \"src/jdk.security.auth/\",\n            \"src/jdk.security.jgss/\",\n            \"test/hotspot/jtreg/compiler/codegen/aes/\",\n            \"test/hotspot/jtreg/compiler/cpuflags/\\\\w*aes\",\n            \"test/hotspot/jtreg/runtime/JVMDoPrivileged/\",\n            \"test/jdk/com/sun/crypto/\",\n            \"test/jdk/com/sun/org/apache/xml/internal/security/\",\n            \"test/jdk/com/sun/security/\",\n            \"test/jdk/java/lang/System/AllowSecurityManager.java\",\n            \"test/jdk/java/security/\",\n            \"test/jdk/javax/crypto/\",\n            \"test/jdk/javax/net/ssl/\",\n            \"test/jdk/javax/security/\",\n            \"test/jdk/javax/xml/crypto/\",\n            \"test/jdk/jdk/jfr/event/security/\",\n            \"test/jdk/jdk/security/\",\n            \"test/jdk/security/\",\n            \"test/jdk/sun/net/www/protocol/https/\",\n            \"test/jdk/sun/security/\",\n            \"test/lib/jdk/test/lib/\\\\w*security\",\n            \"test/lib/jdk/test/lib/net/\\\\w*(ssl|keys)\",\n            \"test/lib/jdk/test/lib/security/\",\n            \"test/micro/org/openjdk/bench/java/security/\",\n            \"test/micro/org/openjdk/bench/javax/crypto/\"\n        ],\n        \"serviceability\": [\n            \"make/jdk/src/classes/build/tools/jdwpgen/\",\n            \"make/modules/java.instrument\",\n            \"make/modules/java.management\",\n            \"make/modules/jdk.attach\",\n            \"make/modules/jdk.hotspot.agent/\",\n            \"make/modules/jdk.jdwp.agent/\",\n            \"make/modules/jdk.management\",\n            \"src/hotspot/share/jfr/instrumentation/\\\\w*jvmti\",\n            \"src/hotspot/share/memory/\\\\w*inspection\",\n            \"src/hotspot/share/memory/metaspace/\\\\w*print\",\n            \"src/hotspot/share/prims/(jvmti|forte).*\",\n            \"src/hotspot/share/services/(attach|management|heapdumper|diagnostic)\",\n            \"src/java.instrument/\",\n            \"src/java.management.rmi/\",\n            \"src/java.management/\",\n            \"src/jdk.attach/\",\n            \"src/jdk.hotspot.agent/\",\n            \"src/jdk.internal.jvmstat/\",\n            \"src/jdk.jcmd/\",\n            \"src/jdk.jconsole/\",\n            \"src/jdk.jdi/\",\n            \"src/jdk.jdwp.agent/\",\n            \"src/jdk.jstatd/\",\n            \"src/jdk.management.agent/\",\n            \"src/jdk.management/\",\n            \"src/java.management.rmi/share/classes/javax/management/\",\n            \"src/jdk.management.agent/\",\n            \"src/jdk.management/\",\n            \"test/hotspot/jtreg/serviceability/\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/aod/\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/jdb\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/jdi/\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/jdwp/\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/jvmti/\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/monitoring/\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/share/jdb\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/share/jdi/\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/share/jdwp\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/share/jpda/\",\n            \"test/hotspot/jtreg/vmTestbase/nsk/share/jvmti/\",\n            \"test/jdk/com/sun/jdi/\",\n            \"test/jdk/com/sun/management/\",\n            \"test/jdk/com/sun/tools/attach/\",\n            \"test/jdk/java/lang/instrument/\",\n            \"test/jdk/java/lang/management/\",\n            \"test/jdk/javax/management/\",\n            \"test/jdk/jdk/internal/platform/\",\n            \"test/jdk/sun/jvmstat/\",\n            \"test/jdk/sun/management/\",\n            \"test/jdk/sun/tools/jcmd/\",\n            \"test/jdk/sun/tools/jconsole/\",\n            \"test/jdk/sun/tools/jhsdb/\",\n            \"test/jdk/sun/tools/jinfo/\",\n            \"test/jdk/sun/tools/jmap/\",\n            \"test/jdk/sun/tools/jps/\",\n            \"test/jdk/sun/tools/jstack/\",\n            \"test/jdk/sun/tools/jstat/\",\n            \"test/jdk/sun/tools/jstatd/\",\n            \"test/lib/jdk/test/lib/util/core\",\n            \"test/lib/jdk/test/lib/SA\",\n            \"test/lib/jdk/test/lib/dcmd/\",\n            \"test/lib/jdk/test/lib/hprof/\",\n            \"test/jdk/com/sun/management/\",\n            \"test/jdk/javax/management/\",\n            \"test/jdk/sun/management/\",\n            \"test/jdk/com/sun/jmx\"\n        ],\n        \"shenandoah\": [\n            \"src/hotspot/cpu/\\\\w+/gc/shenandoah/\",\n            \"src/hotspot/share/gc/shenandoah/\",\n            \"src/jdk.hotspot.agent/share/classes/sun/jvm/hotspot/gc/shenandoah/\",\n            \"test/hotspot/jtreg/gc/shenandoah/\"\n        ]\n    },\n    \"groups\": {\n        \"hotspot\": [\n            \"hotspot-compiler\",\n            \"hotspot-gc\",\n            \"hotspot-runtime\",\n            \"hotspot-jfr\"\n        ]\n    }\n}\n"
  },
  {
    "path": "deps.env",
    "content": "JDK_LINUX_X64_URL=\"https://download.java.net/java/GA/jdk21.0.1/415e3f918a1f4062a0074a2794853d0d/12/GPL/openjdk-21.0.1_linux-x64_bin.tar.gz\"\nJDK_LINUX_X64_SHA256=\"7e80146b2c3f719bf7f56992eb268ad466f8854d5d6ae11805784608e458343f\"\n\nJDK_LINUX_AARCH64_URL=\"https://download.java.net/java/GA/jdk21.0.1/415e3f918a1f4062a0074a2794853d0d/12/GPL/openjdk-21.0.1_linux-aarch64_bin.tar.gz\"\nJDK_LINUX_AARCH64_SHA256=\"f5e4e4622756fafe05ac0105a8efefa1152c8aad085a2bbb9466df0721bf2ba4\"\n\nJDK_MACOS_X64_URL=\"https://download.java.net/java/GA/jdk21.0.1/415e3f918a1f4062a0074a2794853d0d/12/GPL/openjdk-21.0.1_macos-x64_bin.tar.gz\"\nJDK_MACOS_X64_SHA256=\"1ca6db9e6c09752f842eee6b86a2f7e51b76ae38e007e936b9382b4c3134e9ea\"\n\nJDK_MACOS_AARCH64_URL=\"https://download.java.net/java/GA/jdk21.0.1/415e3f918a1f4062a0074a2794853d0d/12/GPL/openjdk-21.0.1_macos-aarch64_bin.tar.gz\"\nJDK_MACOS_AARCH64_SHA256=\"9760eaa019b6d214a06bd44a304f3700ac057d025000bdfb9739b61080969a96\"\n\nJDK_WINDOWS_X64_URL=\"https://download.java.net/java/GA/jdk21.0.1/415e3f918a1f4062a0074a2794853d0d/12/GPL/openjdk-21.0.1_windows-x64_bin.zip\"\nJDK_WINDOWS_X64_SHA256=\"77ea464f4fa7cbcbffe0124af44707e8e5ad8c1ce2373f1d94a64d9b20ba0c69\"\n\nGRADLE_URL=\"https://services.gradle.org/distributions/gradle-8.5-bin.zip\"\nGRADLE_SHA256=\"9d926787066a081739e8200858338b4a69e837c3a821a33aca9db09dd4a41026\"\n"
  },
  {
    "path": "email/build.gradle",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.email'\n    test {\n        requires 'org.openjdk.skara.test'\n        requires 'org.junit.jupiter.api'\n        opens 'org.openjdk.skara.email' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    testImplementation project(':test')\n}\n\npublishing {\n    publications {\n        email(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "email/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.email {\n    requires java.logging;\n\n    exports org.openjdk.skara.email;\n}\n"
  },
  {
    "path": "email/src/main/java/org/openjdk/skara/email/Email.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.email;\n\nimport java.io.UnsupportedEncodingException;\nimport java.nio.charset.StandardCharsets;\nimport java.time.ZonedDateTime;\nimport java.time.format.*;\nimport java.time.temporal.ChronoUnit;\nimport java.util.*;\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class Email {\n    private final EmailAddress id;\n    private final ZonedDateTime date;\n    private final List<EmailAddress> recipients;\n    private final EmailAddress author;\n    private final EmailAddress sender;\n    private final String subject;\n    private final String body;\n    private final Map<String, String> headers;\n\n    private final static Pattern mboxMessageHeaderBodyPattern = Pattern.compile(\n            \"(\\\\r\\\\n){2}|(\\\\n){2}\", Pattern.MULTILINE);\n    private final static Pattern mboxMessageHeaderPattern = Pattern.compile(\n            \"^([-\\\\w]+):\\\\R? ((?:.(?!\\\\R\\\\w))*.)\", Pattern.MULTILINE | Pattern.DOTALL);\n    private final static Pattern mimeHeadersPattern = Pattern.compile(\n            \"^(Content-Type|Content-Transfer-Encoding): .*\");\n    private final static Pattern charsetPattern = Pattern.compile(\"charset=\\\"([a-zA-Z0-9-]+)\\\"\");\n\n    Email(EmailAddress id, ZonedDateTime date, List<EmailAddress> recipients, EmailAddress author, EmailAddress sender, String subject, String body, Map<String, String> headers) {\n        this.id = id;\n        this.date = date.truncatedTo(ChronoUnit.SECONDS);\n        this.recipients = new ArrayList<>(recipients);\n        this.sender = sender;\n        this.subject = subject;\n        this.body = body;\n        this.author = author;\n        this.headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);\n        this.headers.putAll(headers);\n    }\n\n    private static class MboxMessage {\n        Map<String, String> headers;\n        String body;\n    }\n\n    private static MboxMessage parseMboxMessage(String message) {\n        var ret = new MboxMessage();\n\n        var parts = mboxMessageHeaderBodyPattern.split(message, 2);\n        var headers = mboxMessageHeaderPattern.matcher(parts[0]).results()\n                                              .collect(Collectors.toMap(match -> match.group(1),\n                                                                        match -> match.group(2)\n                                                                                      .replaceAll(\"\\\\R\", \"\")));\n        ret.headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);\n        ret.headers.putAll(headers);\n\n        var boundary = extractContentBoundary(ret.headers);\n        if (boundary != null) {\n            var body = new StringBuilder();\n            var bodySections = parts[1].split(\"\\\\R?--\" + boundary + \"(?:--)?\\\\R\");\n            for (String bodySection : bodySections) {\n                if (bodySection.lines().findFirst().map(e -> mimeHeadersPattern.matcher(e).matches()).orElse(false)) {\n                    var mimeHeaders = bodySection.lines()\n                            .takeWhile(s -> !s.isEmpty())\n                            .map(mboxMessageHeaderPattern::matcher)\n                            .filter(Matcher::matches)\n                            .collect(Collectors.toMap(match -> match.group(1), match -> match.group(2)));\n                    // Skip any non plain text part\n                    if (mimeHeaders.containsKey(\"Content-Type\") && !mimeHeaders.get(\"Content-Type\").startsWith(\"text/plain\")) {\n                        continue;\n                    }\n                    // Remove the mime headers from the rest of the body section\n                    var bodySectionBody = bodySection.split(\"\\\\R{2}\", 2)[1];\n                    // Mailman3 encodes mail bodies with \"quoted-printable\".\n                    if (\"quoted-printable\".equals(mimeHeaders.get(\"Content-Transfer-Encoding\"))) {\n                        Matcher encodingMatcher = charsetPattern.matcher(mimeHeaders.get(\"Content-Type\"));\n                        String charsetName;\n                        if (encodingMatcher.find()) {\n                            charsetName = encodingMatcher.group(1);\n                        } else {\n                            charsetName = \"utf-8\";\n                        }\n                        bodySectionBody = decodeQuotedPrintable(bodySectionBody, charsetName);\n                    }\n                    body.append(bodySectionBody.stripTrailing());\n                } else {\n                    body.append(bodySection.stripTrailing());\n                }\n            }\n            ret.body = body.toString();\n        } else {\n            ret.body = parts[1].stripTrailing();\n        }\n        return ret;\n    }\n\n    /**\n     * Decode quoted printable encoding text. Non ASCII characters are encoded\n     * as series of `=XX` where `XX` is the hex value of a byte. Newlines in\n     * the encoding are escaped with `=`.\n     * @param s The string to be decoded.\n     * @param charsetName The charset name to use when converting bytes to a\n     *                    back to a String.\n     * @return A String with the decoded contents.\n     */\n    private static String decodeQuotedPrintable(String s, String charsetName) {\n        byte[] in = s.getBytes(StandardCharsets.US_ASCII);\n        // The decoded buffer can never be longer than the encoded buffer as\n        // every decoding step reduces bytes.\n        byte[] out = new byte[in.length];\n        int j = 0;\n        for (int i = 0; i < in.length; i++) {\n            if (in[i] == '=') {\n                i++;\n                switch (in[i]) {\n                    case '\\n' : break;\n                    case '\\r' : {\n                        if (in[i + 1] == '\\n') {\n                            i++;\n                        }\n                        break;\n                    }\n                    default : {\n                        out[j++] = (byte) Integer.parseInt(\"\" + (char) in[i++] + (char) in[i], 16);\n                        break;\n                    }\n                }\n            } else {\n                out[j++] = in[i];\n            }\n        }\n        try {\n            return new String(out, 0, j, charsetName);\n        } catch (UnsupportedEncodingException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    private final static Pattern mboxBoundaryPattern = Pattern.compile(\".*boundary=\\\"([^\\\"]*)\\\".*\");\n\n    // Content-Type: multipart/mixed; boundary=\"===============3685582790409215631==\"\n    private static String extractContentBoundary(Map<String, String> headers) {\n        if (headers.containsKey(\"Content-Type\")) {\n            var contentType = headers.get(\"Content-Type\");\n            var matcher = mboxBoundaryPattern.matcher(contentType);\n            if (matcher.matches()) {\n                return matcher.group(1);\n            }\n        }\n        return null;\n    }\n\n    private static final Pattern redundantTimeZonePattern = Pattern.compile(\"^(.*[-+\\\\d{4}]) \\\\(\\\\w+\\\\)$\");\n\n    public static Email parse(String raw) {\n        var message = parseMboxMessage(raw);\n\n        var id = EmailAddress.parse(message.headers.get(\"Message-Id\"));\n        var unparsedDate = message.headers.get(\"Date\");\n        var redundantTimeZonePatternMatcher = redundantTimeZonePattern.matcher(unparsedDate);\n        if (redundantTimeZonePatternMatcher.matches()) {\n            unparsedDate = redundantTimeZonePatternMatcher.group(1);\n        }\n        var date = ZonedDateTime.parse(unparsedDate, DateTimeFormatter.RFC_1123_DATE_TIME);\n        var subject = MimeText.decode(message.headers.get(\"Subject\"));\n        var author = EmailAddress.parse(MimeText.decode(message.headers.get(\"From\")));\n        var sender = author;\n        if (message.headers.containsKey(\"Sender\")) {\n            sender = EmailAddress.parse(MimeText.decode(message.headers.get(\"Sender\")));\n        }\n        List<EmailAddress> recipients;\n        if (message.headers.containsKey(\"To\")) {\n            recipients = Arrays.stream(message.headers.get(\"To\").split(\",\"))\n                               .map(MimeText::decode)\n                               .map(String::strip)\n                               .map(EmailAddress::parse)\n                               .collect(Collectors.toList());\n        } else {\n            recipients = List.of();\n        }\n\n        // Remove all known headers\n        var filteredHeaders = message.headers.entrySet().stream()\n                                             .filter(entry -> !entry.getKey().equalsIgnoreCase(\"Message-Id\"))\n                                             .filter(entry -> !entry.getKey().equalsIgnoreCase(\"Date\"))\n                                             .filter(entry -> !entry.getKey().equalsIgnoreCase(\"Subject\"))\n                                             .filter(entry -> !entry.getKey().equalsIgnoreCase(\"From\"))\n                                             .filter(entry -> !entry.getKey().equalsIgnoreCase(\"Sender\"))\n                                             .filter(entry -> !entry.getKey().equalsIgnoreCase(\"To\"))\n                                             .filter(entry -> !entry.getKey().equalsIgnoreCase(\"Content-type\"))\n                                             .collect(Collectors.toMap(Map.Entry::getKey,\n                                                                       entry -> MimeText.decode(entry.getValue())));\n\n        return new Email(id, date, recipients, author, sender, subject, message.body, filteredHeaders);\n    }\n\n    public static EmailBuilder create(EmailAddress author, String subject, String body) {\n        return new EmailBuilder(author, subject, body);\n    }\n\n    public static EmailBuilder create(String subject, String body) {\n        return new EmailBuilder(subject, body);\n    }\n\n    public static EmailBuilder from(Email email) {\n        return new EmailBuilder(email.author, email.subject, email.body)\n                .sender(email.sender)\n                .recipients(email.recipients)\n                .id(email.id)\n                .date(email.date)\n                .headers(email.headers);\n    }\n\n    public static EmailBuilder reply(Email parent, String subject, String body) {\n        var references = parent.id().toString();\n        if (parent.hasHeader(\"References\")) {\n            references = parent.headerValue(\"References\") + \" \" + references;\n        }\n\n        return new EmailBuilder(subject, body)\n                .header(\"In-Reply-To\", parent.id().toString())\n                .header(\"References\", references);\n    }\n\n    public static EmailBuilder reparent(Email newParent, Email email) {\n        var currentParent = email.headerValue(\"In-Reply-To\");\n        var currentRefs = email.headerValue(\"References\");\n\n        return from(email).header(\"In-Reply-To\", newParent.id.toString())\n                          .header(\"References\", currentRefs.replace(currentParent, newParent.id.toString()));\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        Email email = (Email) o;\n        return id.equals(email.id) &&\n                date.toEpochSecond() == email.date.toEpochSecond() &&\n                recipients.equals(email.recipients) &&\n                author.equals(email.author) &&\n                sender.equals(email.sender) &&\n                subject.equals(email.subject) &&\n                body.equals(email.body) &&\n                headers.equals(email.headers);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(id, date.toEpochSecond(), recipients, author, sender, subject, body, headers);\n    }\n\n    public EmailAddress id() {\n        return id;\n    }\n\n    public List<EmailAddress> recipients() {\n        return new ArrayList<>(recipients);\n    }\n\n    public EmailAddress author() {\n        return author;\n    }\n\n    public EmailAddress sender() {\n        return sender;\n    }\n\n    public ZonedDateTime date() {\n        return date;\n    }\n\n    public String subject() {\n        return subject;\n    }\n\n    public String body() {\n        return body;\n    }\n\n    public Set<String> headers() {\n        return new HashSet<>(headers.keySet());\n    }\n\n    public boolean hasHeader(String header) {\n        return headers.containsKey(header);\n    }\n\n    public String headerValue(String header) {\n        return headers.get(header);\n    }\n\n    @Override\n    public String toString() {\n        return \"Email{\" +\n                \"id='\" + id + '\\'' +\n                \", date=\" + date +\n                \", recipients=\" + recipients +\n                \", author=\" + author +\n                \", sender=\" + sender +\n                \", subject='\" + subject + '\\'' +\n                \", body='\" + body + '\\'' +\n                \", headers=\" + headers +\n                '}';\n    }\n}\n"
  },
  {
    "path": "email/src/main/java/org/openjdk/skara/email/EmailAddress.java",
    "content": "/*\n * Copyright (c) 2019, 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.email;\n\nimport java.util.*;\nimport java.util.regex.Pattern;\n\npublic class EmailAddress {\n    private String fullName;\n    private String localPart;\n    private String domain;\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        EmailAddress that = (EmailAddress) o;\n        return Objects.equals(fullName, that.fullName) &&\n                Objects.equals(localPart, that.localPart) &&\n                Objects.equals(domain, that.domain);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(fullName, localPart, domain);\n    }\n\n    private EmailAddress(String fullName, String localPart, String domain) {\n        this.fullName = fullName;\n        this.localPart = localPart;\n        this.domain = domain;\n    }\n\n    private final static Pattern decoratedAddressPattern = Pattern.compile(\"(?<name>.*?)(?:\\\\s*)<(?<local>.*)@(?<domain>.*?)>\");\n    private final static Pattern obfuscatedPattern = Pattern.compile(\"(?<local>.*) at (?<domain>.*) \\\\((?<name>.*)\\\\)\");\n    private final static Pattern plainAddressPattern = Pattern.compile(\"(?<name>)(?<local>.*)@(?<domain>.*?)\");\n    private final static Pattern unqualifiedDecoratedAddressPattern = Pattern.compile(\"(?<name>.*?)(?:\\\\s*)<(?<local>.*)(?<domain>)>\");\n\n    public static EmailAddress parse(String address) {\n        var matcher = decoratedAddressPattern.matcher(address);\n        if (!matcher.matches()) {\n            matcher = obfuscatedPattern.matcher(address);\n            if (!matcher.matches()) {\n                matcher = plainAddressPattern.matcher(address);\n                if (!matcher.matches()) {\n                    matcher = unqualifiedDecoratedAddressPattern.matcher(address);\n                    if (!matcher.matches()) {\n                        throw new IllegalArgumentException(\"Cannot parse email address: \" + address);\n                    }\n                }\n            }\n        }\n        return new EmailAddress(matcher.group(\"name\"), matcher.group(\"local\"), matcher.group(\"domain\"));\n    }\n\n    public static EmailAddress from(String fullName, String address) {\n        return EmailAddress.parse(fullName + \" <\" + address + \">\");\n    }\n\n    public static EmailAddress from(String address) {\n        return EmailAddress.parse(\"<\" + address + \">\");\n    }\n\n    public Optional<String> fullName() {\n        if ((fullName != null) && (fullName.length() > 0)) {\n            return Optional.of(fullName);\n        } else {\n            return Optional.empty();\n        }\n    }\n\n    public String address() {\n        return localPart + \"@\" + domain;\n    }\n\n    public String localPart() {\n        return localPart;\n    }\n\n    public String domain() {\n        return domain;\n    }\n\n    @Override\n    public String toString() {\n        if (fullName().isPresent()) {\n            return fullName().get() + \" <\" + address() + \">\";\n        } else {\n            return \"<\" + address() + \">\";\n        }\n    }\n\n    public String toObfuscatedString() {\n        var ret = localPart + \" at \" + domain;\n        if (fullName().isPresent()) {\n            ret += \" (\" + fullName + \")\";\n        }\n        return ret;\n    }\n}\n"
  },
  {
    "path": "email/src/main/java/org/openjdk/skara/email/EmailBuilder.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.email;\n\nimport java.time.ZonedDateTime;\nimport java.util.*;\n\npublic class EmailBuilder {\n    private EmailAddress author;\n    private String subject;\n    private String body;\n    private EmailAddress sender;\n    private EmailAddress id;\n    private ZonedDateTime date;\n\n    private final List<EmailAddress> recipients = new ArrayList<>();\n    private final Map<String, String> headers = new HashMap<>();\n\n    EmailBuilder(String subject, String body) {\n        this.subject = subject;\n        this.body = body;\n\n        date = ZonedDateTime.now();\n    }\n    EmailBuilder(EmailAddress author, String subject, String body) {\n        this(subject, body);\n        author(author);\n    }\n\n    public EmailBuilder reply(Email parent) {\n        var references = parent.id().toString();\n        if (parent.hasHeader(\"References\")) {\n            references = parent.headerValue(\"References\") + \" \" + references;\n        }\n        header(\"In-Reply-To\", parent.id().toString());\n        header(\"References\", references);\n        return this;\n    }\n\n    public EmailBuilder author(EmailAddress author) {\n        this.author = author;\n        return this;\n    }\n\n    public EmailBuilder subject(String subject) {\n        this.subject = subject;\n        return this;\n    }\n\n    public EmailBuilder body(String body) {\n        this.body = body;\n        return this;\n    }\n\n    public EmailBuilder sender(EmailAddress sender) {\n        this.sender = sender;\n        return this;\n    }\n\n    public EmailBuilder id(EmailAddress id) {\n        this.id = id;\n        return this;\n    }\n\n    public EmailBuilder recipient(EmailAddress recipient) {\n        recipients.add(recipient);\n        return this;\n    }\n\n    public EmailBuilder recipients(List<EmailAddress> recipients) {\n        this.recipients.addAll(recipients);\n        return this;\n    }\n\n    public EmailBuilder header(String key, String value) {\n        headers.put(key, value);\n        return this;\n    }\n\n    public EmailBuilder headers(Map<String, String> headers) {\n        this.headers.putAll(headers);\n        return this;\n    }\n\n    public EmailBuilder replaceHeaders(Map<String, String> headers) {\n        this.headers.clear();\n        this.headers.putAll(headers);\n        return this;\n    }\n\n    public EmailBuilder date(ZonedDateTime date) {\n        this.date = date;\n        return this;\n    }\n\n    public Email build() {\n        if (id == null) {\n            id = EmailAddress.from(UUID.randomUUID() + \"@\" + author.domain());\n        }\n        if (sender == null) {\n            sender = author;\n        }\n        return new Email(id, date, recipients, author, sender, subject, body, headers);\n    }\n}\n"
  },
  {
    "path": "email/src/main/java/org/openjdk/skara/email/MimeText.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.email;\n\nimport java.io.UnsupportedEncodingException;\nimport java.nio.charset.StandardCharsets;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\npublic class MimeText {\n    private final static Pattern encodePattern = Pattern.compile(\"([^\\\\x00-\\\\x7f]+)\");\n    private final static Pattern decodePattern = Pattern.compile(\"=\\\\?([A-Za-z0-9_.-]+)\\\\?([bBqQ])\\\\?(.*?)\\\\?=\");\n    private final static Pattern decodeQuotedPrintablePattern = Pattern.compile(\"=([0-9A-F]{2})\");\n\n    public static String encode(String raw) {\n        var words = raw.split(\" \");\n        var encodedWords = new ArrayList<String>();\n        var lastEncoded = false;\n        for (var word : words) {\n            var needsQuotePattern = encodePattern.matcher(word);\n            if (needsQuotePattern.find()) {\n                if (lastEncoded) {\n                    // Spaces between encoded words are ignored, so add an explicit one\n                    encodedWords.add(\"=?UTF-8?B?IA==?=\");\n                }\n                encodedWords.add(\"=?UTF-8?B?\" + Base64.getEncoder().encodeToString(word.getBytes(StandardCharsets.UTF_8)) + \"?=\");\n                lastEncoded = true;\n            } else {\n                encodedWords.add(word);\n                lastEncoded = false;\n            }\n        }\n        return String.join(\" \", encodedWords);\n    }\n\n    public static String decode(String encoded) {\n        var decoded = new StringBuilder();\n        var quotedMatcher = decodePattern.matcher(encoded);\n        var lastMatchEnd = 0;\n        while (quotedMatcher.find()) {\n            if (quotedMatcher.start() > lastMatchEnd) {\n                var separator = encoded.substring(lastMatchEnd, quotedMatcher.start());\n                if (!separator.isBlank()) {\n                    decoded.append(separator);\n                }\n            }\n            if (quotedMatcher.group(2).toUpperCase().equals(\"B\")) {\n                try {\n                    decoded.append(new String(Base64.getDecoder().decode(quotedMatcher.group(3)), quotedMatcher.group(1)));\n                } catch (UnsupportedEncodingException e) {\n                    throw new RuntimeException(e);\n                }\n            } else {\n                var quotedDecodedSpaces = quotedMatcher.group(3).replace(\"_\", \" \");\n                var quotedPrintableMatcher = decodeQuotedPrintablePattern.matcher(quotedDecodedSpaces);\n                var decodedAscii = quotedPrintableMatcher.replaceAll(qmo -> {\n                    var byteValue = new byte[1];\n                    byteValue[0] = (byte)Integer.parseInt(qmo.group(1), 16);\n                    return new String(byteValue, StandardCharsets.ISO_8859_1);\n                });\n                try {\n                    decoded.append(new String(decodedAscii.getBytes(StandardCharsets.ISO_8859_1), quotedMatcher.group(1)));\n                } catch (UnsupportedEncodingException e) {\n                    throw new RuntimeException(e);\n                }\n            }\n            lastMatchEnd = quotedMatcher.end();\n        }\n        if (lastMatchEnd < encoded.length()) {\n            decoded.append(encoded, lastMatchEnd, encoded.length());\n        }\n        return decoded.toString();\n    }\n}\n"
  },
  {
    "path": "email/src/main/java/org/openjdk/skara/email/SMTP.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.email;\n\nimport java.io.*;\nimport java.net.Socket;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.time.format.DateTimeFormatter;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\n/**\n * Limited SMTP client implementation - only compatibility requirement (currently) is the OpenJDK\n * mailing list servers.\n */\npublic class SMTP {\n    private static Pattern initReply = Pattern.compile(\"^220 .*\");\n    private static Pattern ehloReply = Pattern.compile(\"^250 .*\");\n    private static Pattern mailReply = Pattern.compile(\"^250 .*\");\n    private static Pattern rcptReply = Pattern.compile(\"^250 .*\");\n    private static Pattern dataReply = Pattern.compile(\"^354 .*\");\n    private static Pattern doneReply = Pattern.compile(\"^250 .*\");\n\n    public static void send(String server, Email email) throws IOException {\n        send(server, email, Duration.ofMinutes(30));\n    }\n\n    public static void send(String server, Email email, Duration timeout) throws IOException {\n        if (email.recipients().isEmpty()) {\n            throw new IllegalArgumentException(\"Attempting to send an email without recipients\");\n        }\n        var port = 25;\n        if (server.contains(\":\")) {\n            var parts = server.split(\":\", 2);\n            server = parts[0];\n            port = Integer.parseInt(parts[1]);\n        }\n        var recipientList = email.recipients().stream()\n                                 .map(EmailAddress::toString)\n                                 .map(MimeText::encode)\n                                 .collect(Collectors.joining(\", \"));\n        try (var socket = new Socket(server, port);\n             var out = new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8);\n             var in = new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)) {\n\n            var session = new SMTPSession(in, out, timeout);\n\n            session.waitForPattern(initReply);\n            session.sendCommand(\"EHLO \" + email.sender().domain(), ehloReply);\n            session.sendCommand(\"MAIL FROM:\" + email.sender().address(), mailReply);\n            for (var recipient : email.recipients()) {\n                session.sendCommand(\"RCPT TO:<\" + recipient.address() + \">\", rcptReply);\n            }\n            session.sendCommand(\"DATA\", dataReply);\n            session.sendCommand(\"From: \" + MimeText.encode(email.author().toString()));\n            session.sendCommand(\"Message-Id: \" + email.id());\n            session.sendCommand(\"Date: \" + email.date().format(DateTimeFormatter.RFC_1123_DATE_TIME));\n            session.sendCommand(\"Sender: \" + MimeText.encode(email.sender().toString()));\n            session.sendCommand(\"To: \" + recipientList);\n            for (var header : email.headers()) {\n                session.sendCommand(header + \": \" + MimeText.encode(email.headerValue(header)));\n            }\n            session.sendCommand(\"Subject: \" + MimeText.encode(email.subject()));\n            session.sendCommand(\"Content-type: text/plain; charset=utf-8\");\n            session.sendCommand(\"\");\n            var escapedBody = email.body().lines()\n                                   .map(line -> line.startsWith(\".\") ? \".\" + line : line)\n                                   .collect(Collectors.joining(\"\\n\"));\n            session.sendCommand(escapedBody);\n            session.sendCommand(\".\", doneReply);\n            session.sendCommand(\"QUIT\");\n        }\n    }\n}\n"
  },
  {
    "path": "email/src/main/java/org/openjdk/skara/email/SMTPSession.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.email;\n\nimport java.io.*;\nimport java.time.*;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\n\npublic class SMTPSession {\n    private final static Logger log = Logger.getLogger(\"org.openjdk.skara.email\");;\n    private final BufferedReader in;\n    private final BufferedWriter out;\n    private final Instant timeout;\n\n    public SMTPSession(InputStreamReader in, OutputStreamWriter out, Duration timeout) {\n        this.in = new BufferedReader(in);\n        this.out = new BufferedWriter(out);\n        this.timeout = Instant.now().plus(timeout);\n    }\n\n    void waitForPattern(Pattern expectedReply) throws IOException {\n        while (Instant.now().isBefore(timeout)) {\n            while (!in.ready()) {\n                try {\n                    Thread.sleep(10);\n                } catch (InterruptedException ignored) {\n                }\n            }\n            var line = in.readLine();\n            var matcher = expectedReply.matcher(line);\n            log.fine(\"< \" + line);\n            if (matcher.matches()) {\n                return;\n            }\n        }\n        throw new RuntimeException(\"Timeout waiting for pattern: \" + expectedReply);\n    }\n\n    public List<String> readLinesUntil(Pattern end) throws IOException {\n        var ret = new ArrayList<String>();\n        while (Instant.now().isBefore(timeout)) {\n            while (!in.ready()) {\n                try {\n                    Thread.sleep(10);\n                } catch (InterruptedException ignored) {\n                }\n            }\n            var line = in.readLine();\n            var matcher = end.matcher(line);\n            log.fine(\"< \" + line);\n            if (matcher.matches()) {\n                return ret;\n            }\n            ret.add(line);\n        }\n        throw new RuntimeException(\"Timeout reading response lines: \" + end);\n    }\n\n    public void sendCommand(String command, Pattern expectedReply) throws IOException {\n        log.fine(\"> \" + command);\n        out.write(command + \"\\n\");\n        out.flush();\n\n        if (expectedReply != null) {\n            waitForPattern(expectedReply);\n        }\n    }\n\n    public void sendCommand(String command) throws IOException {\n        sendCommand(command, null);\n    }\n}\n"
  },
  {
    "path": "email/src/test/java/org/openjdk/skara/email/EmailAddressTests.java",
    "content": "/*\n * Copyright (c) 2019, 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.email;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass EmailAddressTests {\n    @Test\n    void simple() {\n        var address = EmailAddress.parse(\"Full Name <full@name.com>\");\n        assertEquals(\"Full Name\", address.fullName().orElseThrow());\n        assertEquals(\"full@name.com\", address.address());\n        assertEquals(\"name.com\", address.domain());\n        assertEquals(\"full\", address.localPart());\n    }\n\n    @Test\n    void noFullName() {\n        var address = EmailAddress.parse(\"<no@name.com>\");\n        assertFalse(address.fullName().isPresent());\n        assertEquals(\"no@name.com\", address.address());\n        assertEquals(\"name.com\", address.domain());\n        assertEquals(\"no\", address.localPart());\n    }\n\n    @Test\n    void noBrackets() {\n        var address = EmailAddress.parse(\"no@brackets.com\");\n        assertFalse(address.fullName().isPresent());\n        assertEquals(\"no@brackets.com\", address.address());\n        assertEquals(\"brackets.com\", address.domain());\n        assertEquals(\"no\", address.localPart());\n    }\n\n    @Test\n    void noDomain() {\n        var address = EmailAddress.parse(\"<noone.ever.>\");\n        assertFalse(address.fullName().isPresent());\n        assertEquals(\"noone.ever.@\", address.address());\n        assertEquals(\"\", address.domain());\n        assertEquals(\"noone.ever.\", address.localPart());\n    }\n}\n"
  },
  {
    "path": "email/src/test/java/org/openjdk/skara/email/EmailTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.email;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.time.*;\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass EmailTests {\n    @Test\n    void parseSimple() {\n        var mail = Email.parse(\"Message-Id: <a@b.c>\\n\" +\n                \"Date: Wed, 27 Mar 2019 14:31:00 +0100\\n\" +\n                \"Subject: hello\\n\" +\n                \"From: B <b@b.c>\\n\" +\n                \"To: C <c@c.c>, <d@d.c>\\n\" +\n                \"\\n\" +\n                \"The body\"\n        );\n\n        assertEquals(EmailAddress.from(\"a@b.c\"), mail.id());\n        assertEquals(\"hello\", mail.subject());\n        assertEquals(EmailAddress.from(\"B\", \"b@b.c\"), mail.author());\n        assertEquals(EmailAddress.from(\"B\", \"b@b.c\"), mail.sender());\n        assertEquals(List.of(EmailAddress.from(\"C\", \"c@c.c\"),\n                             EmailAddress.from(\"d@d.c\")),\n                     mail.recipients());\n        assertEquals(\"The body\", mail.body());\n    }\n\n    @Test\n    void buildFrom() {\n        var original = Email.create(EmailAddress.from(\"A\", \"a@b.c\"), \"Subject\", \"body\")\n                            .header(\"X\", \"y\")\n                            .header(\"Z\", \"a\")\n                            .recipient(EmailAddress.from(\"B\", \"b@b.c\"))\n                            .build();\n        var copy = Email.from(original).build();\n        assertEquals(\"Subject\", copy.subject());\n        assertEquals(\"body\", copy.body());\n        assertEquals(Set.of(\"X\", \"Z\"), copy.headers());\n        assertEquals(\"y\", copy.headerValue(\"X\"));\n        assertEquals(\"a\", copy.headerValue(\"z\"));\n        assertEquals(original, copy);\n    }\n\n    @Test\n    void reparent() {\n        var first = Email.create(EmailAddress.from(\"A\", \"a@b.c\"), \"First\", \"body\")\n                         .recipient(EmailAddress.from(\"B\", \"b@b.c\"))\n                         .build();\n        var second = Email.create(EmailAddress.from(\"A\", \"a@b.c\"), \"Second\", \"body\")\n                          .recipient(EmailAddress.from(\"B\", \"b@b.c\"))\n                          .build();\n        var reply = Email.reply(first, \"The reply\", \"reply body\")\n                         .author(EmailAddress.from(\"C\", \"c@b.c\"))\n                         .build();\n        assertEquals(first.id().toString(), reply.headerValue(\"In-Reply-To\"));\n        assertEquals(first.id().toString(), reply.headerValue(\"References\"));\n        var updated = Email.reparent(second, reply).build();\n        assertEquals(second.id().toString(), updated.headerValue(\"In-Reply-To\"));\n        assertEquals(second.id().toString(), updated.headerValue(\"References\"));\n    }\n\n    @Test\n    void caseInsensitiveHeaders() {\n        var mail = Email.parse(\"Message-ID: <a@b.c>\\n\" +\n                                       \"date: Wed, 27 Mar 2019 14:31:00 +0100\\n\" +\n                                       \"SUBJECT: hello\\n\" +\n                                       \"FRom: B <b@b.c>\\n\" +\n                                       \"tO: C <c@c.c>, <d@d.c>\\n\" +\n                                       \"Extra-header: hello\\n\" +\n                                       \"\\n\" +\n                                       \"The body\"\n        );\n\n        assertEquals(EmailAddress.from(\"a@b.c\"), mail.id());\n        assertEquals(\"hello\", mail.subject());\n        assertEquals(EmailAddress.from(\"B\", \"b@b.c\"), mail.author());\n        assertEquals(EmailAddress.from(\"B\", \"b@b.c\"), mail.sender());\n        assertEquals(List.of(EmailAddress.from(\"C\", \"c@c.c\"),\n                             EmailAddress.from(\"d@d.c\")),\n                     mail.recipients());\n        assertEquals(\"The body\", mail.body());\n        assertEquals(Set.of(\"Extra-header\"), mail.headers());\n        assertEquals(\"hello\", mail.headerValue(\"ExTra-HeaDer\"));\n    }\n\n    @Test\n    void redundantTimeZone() {\n        var mail = Email.parse(\"Message-Id: <a@b.c>\\n\" +\n                                       \"Date: Wed, 27 Mar 2019 14:31:00 +0700 (PDT)\\n\" +\n                                       \"Subject: hello\\n\" +\n                                       \"From: B <b@b.c>\\n\" +\n                                       \"To: C <c@c.c>, <d@d.c>\\n\" +\n                                       \"\\n\" +\n                                       \"The body\"\n        );\n        assertEquals(ZonedDateTime.of(2019, 3, 27, 14, 31, 0, 0, ZoneOffset.ofHours(7)), mail.date());\n        assertEquals(EmailAddress.from(\"a@b.c\"), mail.id());\n        assertEquals(\"hello\", mail.subject());\n        assertEquals(EmailAddress.from(\"B\", \"b@b.c\"), mail.author());\n        assertEquals(EmailAddress.from(\"B\", \"b@b.c\"), mail.sender());\n        assertEquals(List.of(EmailAddress.from(\"C\", \"c@c.c\"),\n                             EmailAddress.from(\"d@d.c\")),\n                     mail.recipients());\n        assertEquals(\"The body\", mail.body());\n    }\n\n    @Test\n    void parseEncoded() {\n        var mail = Email.parse(\"Message-Id: <a@b.c>\\n\" +\n                                       \"Date: Wed, 27 Mar 2019 14:31:00 +0100\\n\" +\n                                       \"Subject: hello\\n\" +\n                                       \"From: r.b at c.d (r =?iso-8859-1?Q?b=E4?=)\\n\" +\n                                       \"To: C <c@c.c>, <d@d.c>\\n\" +\n                                       \"\\n\" +\n                                       \"The body\"\n        );\n\n        assertEquals(EmailAddress.from(\"a@b.c\"), mail.id());\n        assertEquals(\"hello\", mail.subject());\n        assertEquals(EmailAddress.from(\"r bä\", \"r.b@c.d\"), mail.author());\n        assertEquals(EmailAddress.from(\"r bä\", \"r.b@c.d\"), mail.sender());\n        assertEquals(List.of(EmailAddress.from(\"C\", \"c@c.c\"),\n                             EmailAddress.from(\"d@d.c\")),\n                     mail.recipients());\n        assertEquals(\"The body\", mail.body());\n    }\n\n    @Test\n    void parseContentType7bit() {\n        var mail = Email.parse(\"\"\"\n                Message-Id: <a@b.c>\n                Date: Wed, 27 Mar 2019 14:31:00 +0100\n                Subject: hello\n                From: B <b@b.c>\n                To: C <c@c.c>, <d@d.c>\n                Content-Type: multipart/mixed; boundary=\"===============3685582790409215631==\"\n\n                --===============3685582790409215631==\n                Content-Type: text/plain; charset=\"utf-8\"\n                Content-Transfer-Encoding: 7bit\n\n                The body text\n\n                --===============3685582790409215631==--\n                \"\"\"\n        );\n\n        assertEquals(\"The body text\", mail.body());\n    }\n\n    @Test\n    void parseContentTypeQuotedPrintable() {\n        var mail = Email.parse(\"\"\"\n                Message-Id: <a@b.c>\n                Date: Wed, 27 Mar 2019 14:31:00 +0100\n                Subject: hello\n                From: B <b@b.c>\n                To: C <c@c.c>, <d@d.c>\n                Content-Type: multipart/mixed; boundary=\"===============3685582790409215631==\"\n\n                --===============3685582790409215631==\n                Content-Type: text/plain; charset=\"utf-8\"\n                Content-Transfer-Encoding: quoted-printable\n\n                A response with weird characters r=C3=A4ksm=C3=B6rg=C3=A5s and a line longer =\n                than 76=20\n                characters=E2=80=A6\n\n                --===============3685582790409215631==--\n                \"\"\"\n        );\n\n        assertEquals(\"A response with weird characters räksmörgås and a line longer than 76 \\ncharacters…\", mail.body());\n    }\n\n    @Test\n    void parseContentTypeMultipart() {\n        var mail = Email.parse(\"\"\"\n                Message-Id: <a@b.c>\n                Date: Wed, 27 Mar 2019 14:31:00 +0100\n                Subject: hello\n                From: B <b@b.c>\n                To: C <c@c.c>, <d@d.c>\n                Content-Type: multipart/mixed; boundary=\"===============3685582790409215631==\"\n\n                --===============3685582790409215631==\n                Content-Type: text/plain; charset=\"utf-8\"\n                Content-Transfer-Encoding: 7bit\n\n                The body text\n\n                --===============3685582790409215631==\n                Content-Type: text/html\n                Content-Transfer-Encoding: base64\n                Content-Disposition: attachment; filename=\"attachment.html\"\n                MIME-Version: 1.0\n\n                PCFET0NUWVBFIGh0bWw+PGh0bWw+PGhlYWQ+CjxtZXRhIGh0dHAtZXF1aXY9IkNvbnRlbnQtVHlw\n                ZSIgY29udGVudD0idGV4dC9odG1sOyBjaGFyc2V0PXV0Zi04Ij4KICA8L2hlYWQ+CiAgPGJvZHk+\n                CiAgICA8aDE+VGhpcyBpcyBhbiBlbWFpbCB3aXRoIGZvcm1hdHRpbmc8L2gxPgogICAgPHA+UGFy\n                YWdyYXBoIHRleHQuPC9wPgogICAgPHByZT5QcmVmb3JtYXQgdGV4dC48L3ByZT4KICA8L2JvZHk+\n                CjwvaHRtbD4K\n\n                --===============3685582790409215631==--\n                \"\"\"\n        );\n\n        assertEquals(\"The body text\", mail.body());\n    }\n\n    @Test\n    void headerWithNewline() {\n        var mail = Email.parse(\"\"\"\n                Message-Id: <a@b.c>\n                Date: Wed, 27 Mar 2019 14:31:00 +0100\n                Subject: hello\n                 hello\n                From: B <b@b.c>\n                To: C <c@c.c>, <d@d.c>\n\n                the body\n                \"\"\"\n        );\n\n        assertEquals(\"hello hello\", mail.subject());\n    }\n\n    @Test\n    void headerStartsWithNewline() {\n        var mail = Email.parse(\"\"\"\n                Message-Id: <a@b.c>\n                Date: Wed, 27 Mar 2019 14:31:00 +0100\n                Subject:\n                 hello\n                From: B <b@b.c>\n                To: C <c@c.c>, <d@d.c>\n\n                the body\n                \"\"\"\n        );\n\n        assertEquals(\"hello\", mail.subject());\n    }\n\n    @Test\n    void headerStartsWithNewlineFirstWordColon() {\n        var mail = Email.parse(\"\"\"\n                Message-Id: <a@b.c>\n                Date: Wed, 27 Mar 2019 14:31:00 +0100\n                Subject:\n                 RFR:\n                From: B <b@b.c>\n                To: C <c@c.c>, <d@d.c>\n\n                the body\n                \"\"\"\n        );\n\n        assertEquals(\"RFR:\", mail.subject());\n    }\n}\n"
  },
  {
    "path": "email/src/test/java/org/openjdk/skara/email/MimeTextTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.email;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass MimeTextTests {\n    @Test\n    void simple() {\n        var encoded = \"=?UTF-8?B?w6XDpMO2?=\";\n        var decoded = \"åäö\";\n        assertEquals(encoded, MimeText.encode(decoded));\n        assertEquals(decoded, MimeText.decode(encoded));\n    }\n\n    @Test\n    void mixed() {\n        var encoded = \"=?UTF-8?B?VMOpc3Q=?=\";\n        var decoded = \"Tést\";\n        assertEquals(encoded, MimeText.encode(decoded));\n        assertEquals(decoded, MimeText.decode(encoded));\n    }\n\n    @Test\n    void multipleWords() {\n        var encoded = \"This is a =?UTF-8?B?dMOpc3Q=?= of =?UTF-8?B?bcO8bHRpcGxl?= words\";\n        var decoded = \"This is a tést of mültiple words\";\n        assertEquals(encoded, MimeText.encode(decoded));\n        assertEquals(decoded, MimeText.decode(encoded));\n    }\n\n    @Test\n    void concatenateTokens() {\n        var encoded = \"=?UTF-8?B?VMOpc3Q=?= =?UTF-8?B?IA==?= =?UTF-8?B?VMOpc3Q=?=\";\n        var decoded = \"Tést Tést\";\n        assertEquals(encoded, MimeText.encode(decoded));\n        assertEquals(decoded, MimeText.decode(encoded));\n    }\n\n    @Test\n    void preserveSpaces() {\n        var encoded = \"spac  es\";\n        var decoded = \"spac  es\";\n        assertEquals(encoded, MimeText.encode(decoded));\n        assertEquals(decoded, MimeText.decode(encoded));\n    }\n\n    @Test\n    void decodeSpaces() {\n        var encoded = \"=?UTF-8?B?VMOpc3Q=?=   =?UTF-8?B?VMOpc3Q=?=   and  \";\n        var decoded = \"TéstTést   and  \";\n        assertEquals(decoded, MimeText.decode(encoded));\n    }\n\n    @Test\n    void decodeIsoQ() {\n        assertEquals(\"Bä\", MimeText.decode(\"=?iso-8859-1?Q?B=E4?=\"));\n    }\n\n    @Test\n    void decodeIsoQSpaces() {\n        assertEquals(\"Bä Bä Bä\", MimeText.decode(\"=?iso-8859-1?Q?B=E4_B=E4=20B=E4?=\"));\n    }\n\n    @Test\n    void multibyte() {\n        assertEquals(\"first.last at example.com (First Lüst)\", MimeText.decode(\"first.last at example.com (=?UTF-8?Q?First_L=C3=BCst?=)\"));\n    }\n}\n"
  },
  {
    "path": "email/src/test/java/org/openjdk/skara/email/SMTPTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.email;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.condition.*;\nimport org.openjdk.skara.test.SMTPServer;\n\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass SMTPTests {\n    @Test\n    void simple() throws IOException {\n        try (var server = new SMTPServer()) {\n            var sender = EmailAddress.from(\"Test\", \"test@test.email\");\n            var recipient = EmailAddress.from(\"Dest\", \"dest@dest.email\");\n            var sentMail = Email.create(sender, \"Subject\", \"Body\").recipient(recipient).build();\n\n            SMTP.send(server.address(), sentMail);\n            var email = server.receive(Duration.ofSeconds(10));\n            assertEquals(sentMail, email);\n        }\n    }\n\n    @Test\n    void withHeader() throws IOException {\n        try (var server = new SMTPServer()) {\n            var sender = EmailAddress.from(\"Test\", \"test@test.email\");\n            var author = EmailAddress.from(\"Auth\", \"auth@test.email\");\n            var recipient = EmailAddress.from(\"Dest\", \"dest@dest.email\");\n            var sentMail = Email.create(author, \"Subject\", \"Body\")\n                                .sender(sender)\n                                .recipient(recipient)\n                                .header(\"Something\", \"Other\")\n                                .build();\n\n            SMTP.send(server.address(), sentMail);\n            var email = server.receive(Duration.ofSeconds(10));\n            assertEquals(sentMail, email);\n        }\n    }\n\n    @Test\n    @DisabledOnOs(OS.WINDOWS)\n    void encoded() throws IOException {\n        try (var server = new SMTPServer()) {\n            var sender = EmailAddress.from(\"Señor Dévèlöper\", \"test@test.email\");\n            var recipient = EmailAddress.from(\"Dêst\", \"dest@dest.email\");\n            var sentMail = Email.create(sender, \"Sübject\", \"Bödÿ\")\n                                .recipient(recipient)\n                                .header(\"Something\", \"Öthè®\")\n                                .build();\n\n            SMTP.send(server.address(), sentMail);\n            var email = server.receive(Duration.ofSeconds(10));\n            assertEquals(sentMail, email);\n        }\n    }\n\n    @Test\n    void timeout() throws IOException {\n        try (var server = new SMTPServer()) {\n            var sender = EmailAddress.from(\"Test\", \"test@test.email\");\n            var recipient = EmailAddress.from(\"Dest\", \"dest@dest.email\");\n            var sentMail = Email.create(sender, \"Subject\", \"Body\").recipient(recipient).build();\n\n            assertThrows(RuntimeException.class, () -> SMTP.send(server.address(), sentMail, Duration.ZERO));\n        }\n    }\n\n    @Test\n    void withDot() throws IOException {\n        try (var server = new SMTPServer()) {\n            var sender = EmailAddress.from(\"Test\", \"test@test.email\");\n            var recipient = EmailAddress.from(\"Dest\", \"dest@dest.email\");\n            var sentMail = Email.create(sender, \"Subject\", \"Body\\n.\\nMore text\").recipient(recipient).build();\n\n            SMTP.send(server.address(), sentMail);\n            var email = server.receive(Duration.ofSeconds(10));\n            assertEquals(sentMail, email);\n        }\n    }\n\n    @Test\n    void multipleRecipients() throws IOException {\n        try (var server = new SMTPServer()) {\n            var sender = EmailAddress.from(\"Test\", \"test@test.email\");\n            var recipient1 = EmailAddress.from(\"Dest1\", \"dest1@dest.email\");\n            var recipient2 = EmailAddress.from(\"Dest2\", \"dest2@dest.email\");\n            var sentMail = Email.create(sender, \"Subject\", \"Body\")\n                                .recipients(List.of(recipient1, recipient2))\n                                .build();\n\n            SMTP.send(server.address(), sentMail);\n            var email = server.receive(Duration.ofSeconds(10));\n            assertEquals(sentMail, email);\n        }\n    }\n}\n"
  },
  {
    "path": "encoding/build.gradle",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.encoding'\n    test {\n        requires 'org.junit.jupiter.api'\n        opens 'org.openjdk.skara.encoding' to 'org.junit.platform.commons'\n    }\n}\n\npublishing {\n    publications {\n        encoding(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "encoding/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.encoding {\n    exports org.openjdk.skara.encoding;\n}\n\n"
  },
  {
    "path": "encoding/src/main/java/org/openjdk/skara/encoding/Base85.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\npackage org.openjdk.skara.encoding;\n\npublic class Base85 {\n    private static int BASE = 85;\n\n    private static byte[] ENCODE = {\n        '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',\n        'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J',\n        'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T',\n        'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd',\n        'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n',\n        'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x',\n        'y', 'z', '!', '#', '$', '%', '&', '(', ')', '*',\n        '+', '-', ';', '<', '=', '>', '?', '@', '^', '_',\n        '`', '{', '|', '}', '~'\n    };\n\n    private static final byte[] DECODE = new byte[128];\n    static {\n        for (byte i = 0; i < ENCODE.length; i++) {\n            DECODE[ENCODE[i]] = i;\n        }\n    }\n\n    private static int div(int dividend, int divisor) {\n        return Integer.divideUnsigned(dividend, divisor);\n    }\n\n    private static int rem(int dividend, int divisor) {\n        return Integer.remainderUnsigned(dividend, divisor);\n    }\n\n    static int group(byte a, byte b, byte c, byte d) {\n        int g = 0;\n        g |= a << 24;\n        g |= b << 16;\n        g |= c << 8;\n        g |= d;\n        return g;\n    }\n\n    static byte[] ungroup(int g) {\n        byte[] bytes = new byte[4];\n        bytes[0] = (byte) ((g & 0xFF000000) >> 24);\n        bytes[1] = (byte) ((g & 0x00FF0000) >> 16);\n        bytes[2] = (byte) ((g & 0x0000FF00) >> 8);\n        bytes[3] = (byte) ((g & 0x000000FF));\n        return bytes;\n    }\n\n    static byte[] encode(int g) {\n        byte[] bytes = new byte[5];\n\n        bytes[4] = ENCODE[rem(g, BASE)];\n\n        g = div(g, BASE);\n        bytes[3] = ENCODE[rem(g, BASE)];\n\n        g = div(g, BASE);\n        bytes[2] = ENCODE[rem(g, BASE)];\n\n        g = div(g, BASE);\n        bytes[1] = ENCODE[rem(g, BASE)];\n\n        g = div(g, BASE);\n        bytes[0] = ENCODE[rem(g, BASE)];\n\n        return bytes;\n    }\n\n    static int decode(byte a, byte b, byte c, byte d, byte e) {\n        int g = 0;\n\n        g = DECODE[a];\n\n        g *= BASE;\n        g += DECODE[b];\n\n        g *= BASE;\n        g += DECODE[c];\n\n        g *= BASE;\n        g += DECODE[d];\n\n        g *= BASE;\n        g += DECODE[e];\n\n        return g;\n    }\n\n    public static byte[] encode(byte[] src) {\n        int r = rem(src.length, 4);\n        int n = div(src.length, 4);\n        byte[] ascii = new byte[(n * 5) + (r == 0 ? 0 : 5)];\n\n        int pos = 0;\n        for (int i = 0; i < (n * 4); i += 4) {\n            int g = group(src[i], src[i + 1], src[i + 2], src[i + 3]);\n            byte[] bytes = encode(g);\n            for (int bi = 0; bi < 5; bi++) {\n                ascii[pos + bi] = bytes[bi];\n            }\n            pos += 5;\n        }\n\n        if (r > 0) {\n            int g = group(src[n * 4], r > 1 ? src[n * 4 + 1] : 0, r > 2 ? src[n * 4 + 2] : 0, (byte) 0);\n            byte[] bytes = encode(g);\n            for (int bi = 0; bi < 5; bi++) {\n                ascii[pos + bi] = bytes[bi];\n            }\n        }\n\n        return ascii;\n    }\n\n\n    public static byte[] decode(byte[] src, int numBytes) {\n        byte[] data = new byte[numBytes];\n        int pos = 0;\n\n        int r = rem(numBytes, 4);\n        int last = r == 0 ? 0 : 5;\n        for (int i = 0; i < (src.length - last); i += 5) {\n            int g = decode(src[i], src[i + 1], src[i + 2], src[i + 3], src[i + 4]);\n            byte[] bytes = ungroup(g);\n            for (int bi = 0; bi < 4; bi++) {\n                data[pos + bi] = bytes[bi];\n            }\n            pos += 4;\n        }\n\n        if (r > 0) {\n            int n = src.length;\n            int g = decode(src[n - 5], src[n - 4], src[n - 3], src[n - 2], src[n - 1]);\n            byte[] bytes = ungroup(g);\n            for (int bi = 0; bi < r; bi++) {\n                data[pos + bi] = bytes[bi];\n            }\n        }\n\n        return data;\n    }\n}\n"
  },
  {
    "path": "encoding/src/test/java/org/openjdk/skara/encoding/Base85Tests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.encoding;\n\nimport java.nio.charset.StandardCharsets;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class Base85Tests {\n    @Test\n    public void testGroupAndUngroup() {\n        byte[] bytes = {0, 1, 2, 3};\n        int g = Base85.group(bytes[0], bytes[1], bytes[2], bytes[3]);\n        assertArrayEquals(bytes, Base85.ungroup(g));\n    }\n\n    @Test\n    public void testGroupAndEncode() {\n        byte[] bytes = {0, 1, 2, 3};\n        int g = Base85.group(bytes[0], bytes[1], bytes[2], bytes[3]);\n        byte[] encoded = Base85.encode(g);\n        assertArrayEquals(new byte[]{48, 48, 57, 67, 54}, encoded);\n    }\n\n    @Test\n    public void testEncodeAndDecode() {\n        byte[] bytes = {0, 1, 2, 3};\n        int g = Base85.group(bytes[0], bytes[1], bytes[2], bytes[3]);\n\n        byte[] encoded = Base85.encode(g);\n        int g2 = Base85.decode(encoded[0], encoded[1], encoded[2], encoded[3], encoded[4]);\n        assertEquals(g, g2);\n        assertArrayEquals(bytes, Base85.ungroup(g2));\n    }\n\n    @Test\n    public void encodeAndDecodeUTF8String() {\n        var s = \"Hello Base85!\";\n        var bytes = s.getBytes(StandardCharsets.UTF_8);\n\n        var encoded = Base85.encode(bytes);\n        var decoded = Base85.decode(encoded, bytes.length);\n        assertEquals(s, new String(decoded, StandardCharsets.UTF_8));\n    }\n\n    @Test\n    public void encodeAndDecodeLongUTF8String() {\n        var s = \"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\";\n        var bytes = s.getBytes(StandardCharsets.UTF_8);\n\n        var encoded = Base85.encode(bytes);\n        var decoded = Base85.decode(encoded, bytes.length);\n        assertEquals(s, new String(decoded, StandardCharsets.UTF_8));\n    }\n}\n"
  },
  {
    "path": "forge/build.gradle",
    "content": "/*\n * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.forge'\n    test {\n        requires 'org.openjdk.skara.test'\n        requires 'org.openjdk.skara.proxy'\n        requires 'org.junit.jupiter.api'\n        requires 'jdk.httpserver'\n        opens 'org.openjdk.skara.forge' to 'org.junit.platform.commons'\n        opens 'org.openjdk.skara.forge.github' to 'org.junit.platform.commons'\n        opens 'org.openjdk.skara.forge.gitlab' to 'org.junit.platform.commons'\n        opens 'org.openjdk.skara.forge.bitbucket' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':vcs')\n    implementation project(':json')\n    implementation project(':ini')\n    implementation project(':process')\n    implementation project(':email')\n    implementation project(':network')\n    implementation project(':host')\n    implementation project(':issuetracker')\n\n    testImplementation project(':test')\n    testImplementation project(':proxy')\n}\n\npublishing {\n    publications {\n        forge(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.forge {\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.json;\n    requires org.openjdk.skara.ini;\n    requires org.openjdk.skara.process;\n    requires org.openjdk.skara.email;\n    requires org.openjdk.skara.network;\n    requires transitive org.openjdk.skara.issuetracker;\n    requires transitive org.openjdk.skara.host;\n    requires java.net.http;\n    requires java.logging;\n\n    exports org.openjdk.skara.forge;\n\n    uses org.openjdk.skara.forge.ForgeFactory;\n\n    provides org.openjdk.skara.forge.ForgeFactory with\n            org.openjdk.skara.forge.github.GitHubForgeFactory,\n            org.openjdk.skara.forge.gitlab.GitLabForgeFactory,\n            org.openjdk.skara.forge.bitbucket.BitbucketForgeFactory;\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/Check.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.net.URI;\nimport java.time.ZonedDateTime;\nimport java.util.*;\n\npublic class Check {\n    private final ZonedDateTime startedAt;\n    private final ZonedDateTime completedAt;\n    private final CheckStatus status;\n    private final Hash hash;\n    private final String metadata;\n    private final String title;\n    private final String summary;\n    private final List<CheckAnnotation> annotations;\n    private final String name;\n    private final URI details;\n\n    Check(String name, Hash hash, CheckStatus status, ZonedDateTime startedAt, ZonedDateTime completedAt,\n          String metadata, String title, String summary, List<CheckAnnotation> annotations, URI details) {\n        this.name = name;\n        this.hash = hash;\n        this.status = status;\n        this.startedAt = startedAt;\n        this.completedAt = completedAt;\n        this.metadata = metadata;\n        this.title = title;\n        this.summary = summary;\n        this.annotations = annotations;\n        this.details = details;\n    }\n\n    public String name() {\n        return name;\n    }\n\n    public Hash hash() {\n        return hash;\n    }\n\n    public CheckStatus status() {\n        return status;\n    }\n\n    public ZonedDateTime startedAt() {\n        return startedAt;\n    }\n\n    public Optional<ZonedDateTime> completedAt() {\n        return Optional.ofNullable(completedAt);\n    }\n\n    public Optional<String> title() {\n        return Optional.ofNullable(title);\n    }\n\n    public Optional<String> summary() {\n        return Optional.ofNullable(summary);\n    }\n\n    public Optional<String> metadata() {\n        return Optional.ofNullable(metadata);\n    }\n\n    public List<CheckAnnotation> annotations() {\n        return annotations;\n    }\n\n    public Optional<URI> details() {\n        return Optional.ofNullable(details);\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/CheckAnnotation.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.util.Optional;\n\npublic class CheckAnnotation {\n    public final String path;\n    private final int startLine;\n    private final int endLine;\n    private final Integer startColumn;\n    private final Integer endColumn;\n    private final String title;\n    private final String message;\n    private final CheckAnnotationLevel level;\n\n    CheckAnnotation(String path, int startLine, int endLine, CheckAnnotationLevel level, Integer startColumn, Integer endColumn, String title, String message) {\n        this.path = path;\n        this.startLine = startLine;\n        this.endLine = endLine;\n        this.startColumn = startColumn;\n        this.endColumn = endColumn;\n        this.title = title;\n        this.message = message;\n        this.level = level;\n    }\n\n    public String path() {\n        return path;\n    }\n\n    public int startLine() {\n        return startLine;\n    }\n\n    public int endLine() {\n        return endLine;\n    }\n\n    public Optional<Integer> startColumn() {\n        return Optional.ofNullable(startColumn);\n    }\n\n    public Optional<Integer> endColumn() {\n        return Optional.ofNullable(endColumn);\n    }\n\n    public Optional<String> title() {\n        return Optional.ofNullable(title);\n    }\n\n    public String message() {\n        return message;\n    }\n\n    public CheckAnnotationLevel level() {\n        return level;\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/CheckAnnotationBuilder.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\npublic class CheckAnnotationBuilder {\n\n    private final String path;\n    private final int startLine;\n    private final int endLine;\n    private final CheckAnnotationLevel level;\n    private final String message;\n\n    private Integer startColumn;\n    private Integer endColumn;\n    private String title;\n\n    private CheckAnnotationBuilder(String path, int startLine, int endLine, CheckAnnotationLevel level, String message) {\n        this.path = path;\n        this.startLine = startLine;\n        this.endLine = endLine;\n        this.level = level;\n        this.message = message;\n    }\n\n    public static CheckAnnotationBuilder create(String path, int startLine, int endLine, CheckAnnotationLevel level, String message) {\n        return new CheckAnnotationBuilder(path, startLine, endLine, level, message);\n    }\n\n    public CheckAnnotationBuilder startColumn(int startColumn) {\n        this.startColumn = startColumn;\n        return this;\n    }\n\n    public CheckAnnotationBuilder endColumn(int endColumn) {\n        this.endColumn = endColumn;\n        return this;\n    }\n\n    public CheckAnnotationBuilder title(String title) {\n        this.title = title;\n        return this;\n    }\n\n    public CheckAnnotation build() {\n        return new CheckAnnotation(path, startLine, endLine, level, startColumn, endColumn, title, message);\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/CheckAnnotationLevel.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\npublic enum CheckAnnotationLevel {\n    NOTICE,\n    WARNING,\n    FAILURE\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/CheckBuilder.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.net.URI;\nimport java.time.*;\nimport java.util.*;\n\npublic class CheckBuilder {\n    private final String name;\n    private final Hash hash;\n\n    private String metadata;\n    private List<CheckAnnotation> annotations;\n    private CheckStatus status;\n    private ZonedDateTime startedAt;\n    private ZonedDateTime completedAt;\n    private String title;\n    private String summary;\n    private URI details;\n\n    private CheckBuilder(String name, Hash hash) {\n        this.name = name;\n        this.hash = hash;\n\n        annotations = new ArrayList<>();\n        status = CheckStatus.IN_PROGRESS;\n        startedAt = ZonedDateTime.now(ZoneOffset.UTC);\n    }\n\n    public static CheckBuilder create(String name, Hash hash) {\n        return new CheckBuilder(name, hash);\n    }\n\n    public static CheckBuilder from(Check c) {\n        var builder = new CheckBuilder(c.name(), c.hash());\n        builder.startedAt = c.startedAt();\n        builder.status = c.status();\n        builder.annotations = c.annotations();\n\n        if (c.title().isPresent()) {\n            builder.title = c.title().get();\n        }\n        if (c.summary().isPresent()) {\n            builder.summary = c.summary().get();\n        }\n        if (c.completedAt().isPresent()) {\n            builder.completedAt = c.completedAt().get();\n        }\n        if (c.metadata().isPresent()) {\n            builder.metadata = c.metadata().get();\n        }\n\n        return builder;\n    }\n\n    public CheckBuilder metadata(String metadata) {\n        this.metadata = metadata;\n        return this;\n    }\n\n    public CheckBuilder annotation(CheckAnnotation annotation) {\n        annotations.add(annotation);\n        return this;\n    }\n\n    public CheckBuilder complete(boolean success) {\n        status = success ? CheckStatus.SUCCESS : CheckStatus.FAILURE;\n        completedAt = ZonedDateTime.now();\n        return this;\n    }\n\n    public CheckBuilder complete(boolean success, ZonedDateTime completedAt) {\n        status = success ? CheckStatus.SUCCESS : CheckStatus.FAILURE;\n        this.completedAt = completedAt;\n        return this;\n    }\n\n    public CheckBuilder cancel() {\n        status = CheckStatus.CANCELLED;\n        completedAt = ZonedDateTime.now();\n        return this;\n    }\n\n    public CheckBuilder cancel(ZonedDateTime completedAt) {\n        status = CheckStatus.CANCELLED;\n        this.completedAt = completedAt;\n        return this;\n    }\n\n    public CheckBuilder startedAt(ZonedDateTime startedAt) {\n        this.startedAt = startedAt;\n        return this;\n    }\n\n    public CheckBuilder title(String title) {\n        this.title = title;\n        return this;\n    }\n\n    public CheckBuilder summary(String summary) {\n        this.summary = summary;\n        return this;\n    }\n\n    public CheckBuilder details(URI details) {\n        this.details = details;\n        return this;\n    }\n\n    public CheckBuilder skipped() {\n        status = CheckStatus.SKIPPED;\n        completedAt = ZonedDateTime.now();\n        return this;\n    }\n\n    public CheckBuilder skipped(ZonedDateTime actionRequiredAt) {\n        status = CheckStatus.SKIPPED;\n        completedAt = ZonedDateTime.now();\n        return this;\n    }\n\n    public CheckBuilder stale() {\n        status = CheckStatus.STALE;\n        completedAt = ZonedDateTime.now();\n        return this;\n    }\n\n    public Check build() {\n        return new Check(name, hash, status, startedAt, completedAt, metadata, title, summary, annotations, details);\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/CheckStatus.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\npublic enum CheckStatus {\n    IN_PROGRESS,\n    SUCCESS,\n    FAILURE,\n    CANCELLED,\n    SKIPPED,\n    STALE\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/Collaborator.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.openjdk.skara.host.HostUser;\n\n/**\n * A repository collaborator is a user and a set of permissions, currently only\n * 'canPush'.\n */\npublic record Collaborator(HostUser user, boolean canPush) {\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/CommitComment.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.nio.file.Path;\nimport java.time.ZonedDateTime;\nimport java.util.*;\n\npublic class CommitComment extends Comment {\n    private Hash commit;\n    private final Path path;\n    private final int line;\n\n    public CommitComment(Hash commit, Path path, int line, String id, String body, HostUser author, ZonedDateTime createdAt, ZonedDateTime updatedAt) {\n        super(id, body, author, createdAt, updatedAt);\n\n        this.commit = commit;\n        this.path = path;\n        this.line = line;\n    }\n\n    /**\n     * Returns the hash of the commit.\n     */\n    public Hash commit() {\n        return commit;\n    }\n\n    /**\n     * Returns the relative path of the file.\n     */\n    public Optional<Path> path() {\n        return Optional.ofNullable(path);\n    }\n\n    /**\n     * Returns the line number in the file.\n     */\n    public Optional<Integer> line() {\n        return line == -1 ? Optional.empty() : Optional.of(line);\n    }\n\n    @Override\n    public String toString() {\n        return commit.hex() + \": \" + body();\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        if (!super.equals(o)) {\n            return false;\n        }\n        var other = (CommitComment) o;\n        return Objects.equals(commit, other.commit) &&\n               Objects.equals(path, other.path) &&\n               line == other.line;\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(super.hashCode(), commit, path, line);\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/CommitFailure.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\npublic class CommitFailure extends Exception {\n    public CommitFailure(String reason) {\n        super(reason);\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/Forge.java",
    "content": "/*\n * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.time.Duration;\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.json.JSONObject;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.net.URI;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\npublic interface Forge extends Host {\n    String name();\n\n    /**\n     * Gets a HostedRepository on this Forge. This method should verify that the\n     * repository exists.\n     * @param name Name of repository to get\n     * @return Optional containing the repository, or empty if the repository\n     *         does not exist on the Forge.\n     */\n    Optional<HostedRepository> repository(String name);\n\n    /**\n     * Search the whole host for a commit by hash.\n     * @param hash Hash to search for\n     * @param includeDiffs Set to true to include parent diffs in Commit, default false\n     * @return Repository name if found, otherwise empty\n     */\n    Optional<String> search(Hash hash, boolean includeDiffs);\n    default Optional<String> search(Hash hash) {\n        return search(hash, false);\n    }\n\n    /**\n     * Get user by numeric ID\n     */\n    Optional<HostUser> userById(String id);\n\n    /**\n     * List users that are members of a group\n     */\n    List<HostUser> groupMembers(String group);\n\n    /**\n     * Gets the membership state for a user in a group\n     */\n    MemberState groupMemberState(String group, HostUser user);\n\n    /**\n     * Adds a user to a group\n     */\n    void addGroupMember(String group, HostUser user);\n\n    /**\n     * Some forges do not always update the \"updated_at\" fields of various objects\n     * when the object changes. This method returns a Duration indicating how long\n     * the shortest update interval is for the \"updated_at\" field. This is needed\n     * to be taken into account when running queries (typically by padding the\n     * timestamp by this duration to guarantee that no results are missed). The\n     * default returns 0 which means no special considerations are needed.\n     */\n    default Duration minTimeStampUpdateInterval() {\n        return Duration.ZERO;\n    }\n\n    /**\n     * Returns a default pull request template for this forge.\n     *\n     * If the forge does not feature a pull request template, then {@link Optional#empty}\n     * will be returned.\n     *\n     * @return the pull request template (if present).\n     */\n    Optional<String> defaultPullRequestTemplate();\n\n    static Forge from(String name, URI uri, Credential credential, JSONObject configuration) {\n        var factory = ForgeFactory.getForgeFactories().stream()\n                                    .filter(f -> f.name().equals(name))\n                                    .findFirst();\n        if (factory.isEmpty()) {\n            throw new RuntimeException(\"No forge factory named '\" + name + \"' found - check module path\");\n        }\n        return factory.get().create(uri, credential, configuration);\n    }\n\n    static Forge from(String name, URI uri, Credential credential) {\n        return from(name, uri, credential, null);\n    }\n\n    static Forge from(String name, URI uri) {\n        return from(name, uri, null, null);\n    }\n\n    static Forge from(String name, URI uri, JSONObject configuration) {\n        return from(name, uri, null, configuration);\n    }\n\n    static Optional<Forge> from(URI uri, Credential credential, JSONObject configuration) {\n        var factories = ForgeFactory.getForgeFactories();\n\n        var hostname = uri.getHost();\n        var knownHostFactories = factories.stream()\n                                          .filter(f -> f.knownHosts().contains(hostname))\n                                          .collect(Collectors.toList());\n        if (knownHostFactories.size() == 1) {\n            var factory = knownHostFactories.get(0);\n            return Optional.of(factory.create(uri, credential, configuration));\n        }\n\n        var sorted = factories.stream()\n                              .sorted(Comparator.comparing(f -> !hostname.contains(f.name())))\n                              .collect(Collectors.toList());\n        for (var factory : sorted) {\n            var forge = factory.create(uri, credential, configuration);\n            if (forge.isValid()) {\n                return Optional.of(forge);\n            }\n        }\n        return Optional.empty();\n    }\n\n    static Optional<Forge> from(URI uri, Credential credential) {\n        return from(uri, credential, null);\n    }\n\n    static Optional<Forge> from(URI uri) {\n        return from(uri, null);\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/ForgeFactory.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.json.JSONObject;\n\nimport java.net.URI;\nimport java.util.*;\nimport java.util.stream.*;\n\npublic interface ForgeFactory {\n    /**\n     * A user-friendly name for the given forge, used for configuration section naming. Should be lower case.\n     * @return\n     */\n    String name();\n\n    /**\n     * A set of known hostnames that are instances of this forge.\n     * @return\n     */\n    Set<String> knownHosts();\n\n    /**\n     * Instantiate an instance of this forge.\n     * @return\n     */\n    Forge create(URI uri, Credential credential, JSONObject configuration);\n\n    static List<ForgeFactory> getForgeFactories() {\n        return StreamSupport.stream(ServiceLoader.load(ForgeFactory.class).spliterator(), false)\n                            .collect(Collectors.toList());\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/HostedBranch.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.util.Objects;\n\npublic class HostedBranch {\n    private final String name;\n    private final Hash hash;\n\n    public HostedBranch(String name, Hash hash) {\n        this.name = name;\n        this.hash = hash;\n    }\n\n    public String name() {\n        return name;\n    }\n\n    public Hash hash() {\n        return hash;\n    }\n\n    @Override\n    public String toString() {\n        return name + \"@\" + hash.hex();\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(name, hash);\n    }\n\n    @Override\n    public boolean equals(Object other) {\n        if (!(other instanceof HostedBranch o)) {\n            return false;\n        }\n\n        return Objects.equals(name, o.name) && Objects.equals(hash, o.hash);\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/HostedCommit.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.openjdk.skara.vcs.*;\n\nimport java.net.URI;\nimport java.util.*;\nimport java.time.*;\nimport java.time.format.*;\n\npublic class HostedCommit extends Commit {\n    private final URI url;\n    private final URI webUrl;\n\n    public HostedCommit(CommitMetadata metadata, List<Diff> parentDiffs, URI url) {\n        this(metadata, parentDiffs, url, url);\n    }\n    public HostedCommit(CommitMetadata metadata, List<Diff> parentDiffs, URI url, URI webUrl) {\n        super(metadata, parentDiffs);\n        this.url = url;\n        this.webUrl = webUrl;\n    }\n\n    public URI url() {\n        return url;\n    }\n\n    public URI webUrl() {\n        return webUrl;\n    }\n\n    @Override\n    public String toString() {\n        return url.toString();\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(url);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof HostedCommit other)) {\n            return false;\n        }\n\n        return Objects.equals(url, other.url);\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/HostedRepository.java",
    "content": "/*\n * Copyright (c) 2018, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.time.Duration;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.json.JSONValue;\nimport org.openjdk.skara.issuetracker.Label;\nimport org.openjdk.skara.vcs.*;\n\nimport java.net.URI;\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\npublic interface HostedRepository {\n    Forge forge();\n    PullRequest createPullRequest(HostedRepository target,\n                                  String targetRef,\n                                  String sourceRef,\n                                  String title,\n                                  List<String> body,\n                                  boolean draft);\n    PullRequest pullRequest(String id);\n\n    /**\n     * Returns a list of all the pull requests (included open and closed).\n     */\n    List<PullRequest> pullRequests();\n\n    /**\n     * Returns a list of all open pull requests.\n     */\n    List<PullRequest> openPullRequests();\n\n    /**\n     * Returns a list of all pull requests (both open and closed) that have\n     * been updated after or on the given time, with a resolution given by\n     * Host::timeStampQueryPrecision, ordered by latest updated first. If there\n     * are many pull requests that match, the list may have been truncated.\n     */\n    List<PullRequest> pullRequestsAfter(ZonedDateTime updatedAfter);\n\n    /**\n     * Returns a list of all open pull requests that have been updated after or on\n     * the given time, with a resolution given by Host::timeStampQueryPrecision.\n     */\n    List<PullRequest> openPullRequestsAfter(ZonedDateTime updatedAfter);\n    List<PullRequest> findPullRequestsWithComment(String author, String body);\n    Optional<PullRequest> parsePullRequestUrl(String url);\n\n    /**\n     * The full name of the repository, including any namespace/group/organization prefix\n     */\n    String name();\n\n    /**\n     * The group/org name where this repo belongs\n     */\n    String group();\n    Optional<HostedRepository> parent();\n    URI authenticatedUrl();\n    URI webUrl();\n    URI nonTransformedWebUrl();\n    URI webUrl(Hash hash);\n    URI webUrl(Branch branch);\n    URI webUrl(Tag tag);\n    URI webUrl(String baseRef, String headRef);\n    URI diffUrl(String prId);\n    VCS repositoryType();\n    /**\n     * Returns a URL suitable for CLI interactions with the repository\n     */\n    URI url();\n\n    /**\n     * Returns contents of the file, if the file does not exist, returns Optional.empty(),\n     * if the ref does not exist, throws exception.\n     */\n    Optional<String> fileContents(String filename, String ref);\n\n    /**\n     * Writes new contents to a file in the repo by creating a new commit.\n     *\n     * @param filename    Name of file inside repository to write to\n     * @param content     New file content to write, always replacing existing content\n     * @param branch      Branch to add commit on top of\n     * @param message     Commit message\n     * @param authorName  Name of author and committer for commit\n     * @param authorEmail Email of author and committer for commit\n     * @param createNewFile Determines the file operation mode\n     *                      If set to `true`, the operation attempts to create a new file and write contents to it.\n     *                      The operation will fail if the file already exists.\n     *                      If set to `false`, the operation attempts to update an existing file.\n     *                      The operation will fail if the file does not exist.\n     */\n    void writeFileContents(String filename, String content, Branch branch, String message, String authorName, String authorEmail, boolean createNewFile);\n    String namespace();\n    Optional<WebHook> parseWebHook(JSONValue body);\n    HostedRepository fork();\n    long id();\n    Optional<Hash> branchHash(String ref);\n    List<HostedBranch> branches();\n    String defaultBranchName();\n\n    /**\n     * Adds a branch protection rule based on a branch pattern. The rule prevents\n     * normal users from pushing to the branch, but still allows admins to force\n     * push.\n     * @param pattern Pattern for branches\n     */\n    void protectBranchPattern(String pattern);\n\n    /**\n     * Removes a branch protection rule based on the branch pattern.\n     * @param pattern Pattern for branches\n     */\n    void unprotectBranchPattern(String pattern);\n    void deleteBranch(String ref);\n    List<CommitComment> commitComments(Hash hash);\n    default List<CommitComment> recentCommitComments() {\n        return recentCommitComments(null, Set.of(), null, ZonedDateTime.now().minus(Duration.ofDays(4)));\n    }\n\n    /**\n     * Fetch recent commit comments from the forge.\n     * @param localRepo Only needed for certain implementations. Needs to be a\n     *                  reasonably up-to-date clone of this repository\n     * @param excludeAuthors Set of authors to exclude from the results\n     * @param Branches Optional list of branches to limit the search to if\n     *                 supported by the implementation.\n     * @param updatedAfter Filter out comments older than this\n     * @return A list of CommitComments\n     */\n    List<CommitComment> recentCommitComments(ReadOnlyRepository localRepo, Set<Integer> excludeAuthors,\n            List<Branch> Branches, ZonedDateTime updatedAfter);\n    CommitComment addCommitComment(Hash hash, String body);\n    void updateCommitComment(String id, String body);\n\n    /**\n     * Gets a Commit instance for a given hash, if present.\n     * @param hash Hash to get Commit for\n     * @param includeDiffs Set to true to include parent diffs in Commit, default false\n     * @return Commit instance for the hash in this repository, empty if not\n     * found.\n     */\n    Optional<HostedCommit> commit(Hash hash, boolean includeDiffs);\n    default Optional<HostedCommit> commit(Hash hash) {\n        return commit(hash, false);\n    }\n    List<Check> allChecks(Hash hash);\n    WorkflowStatus workflowStatus();\n    URI createPullRequestUrl(HostedRepository target,\n                             String targetRef,\n                             String sourceRef);\n    List<Collaborator> collaborators();\n    void addCollaborator(HostUser user, boolean canPush);\n    void removeCollaborator(HostUser user);\n    boolean canPush(HostUser user);\n    void restrictPushAccess(Branch branch, HostUser users);\n    List<Label> labels();\n    void addLabel(Label label);\n    void updateLabel(Label label);\n    void deleteLabel(Label label);\n\n    default PullRequest createPullRequest(HostedRepository target,\n                                          String targetRef,\n                                          String sourceRef,\n                                          String title,\n                                          List<String> body) {\n        return createPullRequest(target, targetRef, sourceRef, title, body, false);\n    }\n\n    default URI reviewUrl(Hash hash) {\n        var comments = this.commitComments(hash);\n        var reviewComment = comments.stream().filter(\n                c -> c.body().startsWith(\"<!-- COMMIT COMMENT NOTIFICATION -->\")).findFirst();\n\n        if (reviewComment.isEmpty()) {\n            return null;\n        }\n\n        /** The review comment looks like this:\n         * <!-- COMMIT COMMENT NOTIFICATION -->\n         * ### Review\n         *\n         * - [openjdk/skara/123](https://git.openjdk.org/skara/pull/123)\n         */\n\n        var pattern = Pattern.compile(\"### Review[^]]*]\\\\((.*)\\\\)\");\n        var matcher = pattern.matcher(reviewComment.get().body());\n        if (matcher.find()) {\n            return URI.create(matcher.group(1));\n        }\n\n        return null;\n    }\n\n    /**\n     * Returns true if this HostedRepository represents the same repo as the other.\n     */\n    default boolean isSame(HostedRepository other) {\n        return name().equals(other.name()) && forge().name().equals(other.forge().name());\n    }\n\n    /**\n     * Delete deploy keys which are older than 'age' in this repository\n     * The return value is the count of deleted keys\n     */\n    int deleteDeployKeys(Duration age);\n\n    /**\n     * Check whether the user is allowed to create pull request in this repository\n     */\n    boolean canCreatePullRequest(HostUser user);\n\n    /**\n     * Returns a list of open pull requests which targets at the specific ref\n     */\n    List<PullRequest> openPullRequestsWithTargetRef(String targetRef);\n\n    /**\n     * Return the titles of expired deploy keys which are older than 'age' in this repository\n     */\n    List<String> deployKeyTitles(Duration age);\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/HostedRepositoryPool.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.nio.file.*;\nimport java.time.*;\nimport java.util.*;\nimport java.util.logging.Logger;\n\npublic class HostedRepositoryPool {\n    private static final String CLONE_TMP_SUFFIX = \".clone-tmp\";\n\n    private final Path seedStorage;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.forge\");\n\n    public HostedRepositoryPool(Path seedStorage) {\n        this.seedStorage = seedStorage;\n    }\n\n    private class HostedRepositoryInstance {\n        private final HostedRepository hostedRepository;\n        private final Path seed;\n        private static Set<Path> healthySet = new HashSet<>();\n\n        private HostedRepositoryInstance(HostedRepository hostedRepository) {\n            this.hostedRepository = hostedRepository;\n            this.seed = seedStorage.resolve(hostedRepository.name() + (hostedRepository.repositoryType() == VCS.GIT ? \".git\" : \"\"));\n        }\n\n        private void clearDirectory(Path directory) {\n            try (var paths = Files.walk(directory)) {\n                paths.map(Path::toFile)\n                     .sorted(Comparator.reverseOrder())\n                     .forEach(File::delete);\n            } catch (IOException io) {\n                throw new RuntimeException(io);\n            }\n        }\n\n        private URI seedUri() {\n            var uri = seed.toUri().toString().replaceAll(\".git[/\\\\\\\\]$\", \".git\");\n            return URI.create(uri);\n        }\n\n        private void refreshSeed(boolean allowStale) throws IOException {\n            if (!Files.exists(seed)) {\n                Files.createDirectories(seed.getParent());\n                var tmpSeedFolder = seed.resolveSibling(seed.getFileName().toString() + \"-\" + UUID.randomUUID());\n                Repository.clone(hostedRepository.authenticatedUrl(), tmpSeedFolder, true);\n                try {\n                    Files.move(tmpSeedFolder, seed);\n                    log.info(\"Seeded repository \" + hostedRepository.name() + \" into \" + seed);\n                    return;\n                } catch (IOException e) {\n                    log.info(\"Failed to populate seed folder \" + seed + \" - perhaps due to a benign race. Ignoring..\");\n                    clearDirectory(tmpSeedFolder);\n                }\n            }\n\n            // If a stale materialization isn't allowed, we will always clone directly from the source and thus do not\n            // need to refresh the seed folder\n            if (!allowStale) {\n                return;\n            }\n            var seedRepo = Repository.get(seed).orElseThrow(() -> new IOException(\"Existing seed is corrupt?\"));\n            try {\n                var lastFetch = Files.getLastModifiedTime(seed.resolve(\"FETCH_HEAD\"));\n                if (lastFetch.toInstant().isAfter(Instant.now().minus(Duration.ofMinutes(1)))) {\n                    log.info(\"Seed should be up to date, skipping fetch\");\n                    return;\n                } else {\n                    log.info(\"Seed is potentially stale, time to fetch the latest upstream changes\");\n                }\n            } catch (IOException ignored) {\n            }\n            try {\n                seedRepo.fetchAll(hostedRepository.authenticatedUrl(), true);\n            } catch (IOException e) {\n                log.info(\"Failed to refresh seed - ignoring\");\n            }\n        }\n\n        private Repository seedRepository(boolean allowStale) throws IOException {\n            refreshSeed(allowStale);\n            return Repository.get(seed).orElseThrow(() -> new IOException(\"Existing seed is corrupt?\"));\n        }\n\n        private Repository cloneSeeded(Path path, boolean allowStale, boolean bare) throws IOException {\n            refreshSeed(true);\n            var remote = allowStale ? seedUri() : hostedRepository.authenticatedUrl();\n            log.info(\"Using seed folder \" + seed + \" when cloning into \" + path + \" from \" + remote + (bare ? \" (bare)\" : \"\"));\n            var tmpClonePath = path.resolveSibling(path.getFileName() + CLONE_TMP_SUFFIX);\n            if (Files.exists(tmpClonePath)) {\n                log.fine(\"Found previous clone attempt \" + tmpClonePath + \" - deleting\");\n                clearDirectory(tmpClonePath);\n            }\n            Repository.clone(remote, tmpClonePath, bare, seed);\n            Files.move(tmpClonePath, path);\n            if (Repository.get(path).isPresent()) {\n                healthySet.add(path);\n            }\n            return Repository.get(path).orElseThrow();\n        }\n\n        private void removeOldClone(Path path, String reason) {\n            if (Files.exists(path)) {\n                log.severe(\"Invalid local repository \" + path + \" detected (\" + reason + \")\");\n                clearDirectory(path);\n            }\n        }\n\n        private Repository materializeClone(Path path, boolean allowStale, boolean bare) throws IOException {\n            var localRepo = Repository.get(path);\n            if (localRepo.isEmpty()) {\n                removeOldClone(path, \"norepo\");\n                return cloneSeeded(path, allowStale, bare);\n            } else {\n                var localRepoInstance = localRepo.get();\n                if (!isHealthy(localRepoInstance, path)) {\n                    removeOldClone(path, \"unhealthy\");\n                    return cloneSeeded(path, allowStale, bare);\n                } else {\n                    try {\n                        refreshSeed(allowStale);\n                        if (!bare) {\n                            localRepoInstance.clean();\n                        }\n                        return localRepoInstance;\n                    } catch (IOException e) {\n                        removeOldClone(path, \"uncleanable\");\n                        return cloneSeeded(path, allowStale, bare);\n                    }\n                }\n            }\n        }\n\n        private boolean isHealthy(Repository localRepoInstance, Path path) throws IOException {\n            if (healthySet.contains(path)) {\n                return true;\n            } else {\n                boolean isHealthy = localRepoInstance.isHealthy();\n                if (isHealthy) {\n                    healthySet.add(path);\n                }\n                return isHealthy;\n            }\n        }\n    }\n\n    public Repository materialize(HostedRepository hostedRepository, Path path) throws IOException {\n        var hostedRepositoryInstance = new HostedRepositoryInstance(hostedRepository);\n        return hostedRepositoryInstance.materializeClone(path, false, false);\n    }\n\n    public Repository materializeBare(HostedRepository hostedRepository, Path path) throws IOException {\n        var hostedRepositoryInstance = new HostedRepositoryInstance(hostedRepository);\n        return hostedRepositoryInstance.materializeClone(path, false, true);\n    }\n\n    private Repository checkout(HostedRepository hostedRepository, String ref, Path path, boolean allowStale) throws IOException {\n        var hostedRepositoryInstance = new HostedRepositoryInstance(hostedRepository);\n        var localClone = hostedRepositoryInstance.materializeClone(path, true, false);\n        var remote = allowStale ? hostedRepositoryInstance.seedUri() : hostedRepository.authenticatedUrl();\n        log.info(\"Updating local repository from: \" + remote);\n        var refHash = localClone.fetch(remote, \"+\" + ref + \":hostedrepositorypool\", true, true).orElseThrow();\n        try {\n            localClone.checkout(refHash, true);\n        } catch (IOException e) {\n            var preserveUnchecked = path.resolveSibling(hostedRepositoryInstance.seed.getFileName().toString() + \"-unchecked-\" + UUID.randomUUID());\n            log.severe(\"Uncheckoutable local repository detected - preserved in: \" + preserveUnchecked);\n            Files.move(localClone.root(), preserveUnchecked);\n            localClone = hostedRepositoryInstance.materializeClone(path, false, false);\n            localClone.checkout(new Branch(ref), true);\n        }\n        return localClone;\n    }\n\n    public Repository checkout(HostedRepository hostedRepository, String ref, Path path) throws IOException {\n        return checkout(hostedRepository, ref, path, false);\n    }\n\n    public Repository checkoutAllowStale(HostedRepository hostedRepository, String ref, Path path) throws IOException {\n        return checkout(hostedRepository, ref, path, true);\n    }\n\n    private void fetchWithRetry(Repository repo, URI url) throws IOException {\n        IOException lastException = null;\n        for (int count = 0; count < 10; ++count) {\n            try {\n                repo.fetchAll(url, true);\n                return;\n            } catch (IOException e) {\n                lastException = e;\n            }\n        }\n\n        throw lastException;\n    }\n\n    public Optional<List<String>> lines(HostedRepository hostedRepository, Path p, String ref) throws IOException {\n        var hostedRepositoryInstance = new HostedRepositoryInstance(hostedRepository);\n        var seedRepo = hostedRepositoryInstance.seedRepository(true);\n        var hash = seedRepo.resolve(ref);\n        if (hash.isEmpty()) {\n            // It may fail because the seed is stale - need to refresh it now\n            fetchWithRetry(seedRepo, hostedRepository.authenticatedUrl());\n            hash = seedRepo.resolve(ref);\n        }\n        var finalHash = hash.orElseThrow(() -> new IllegalArgumentException(\"Unknown ref: \" + ref));\n        return seedRepo.lines(p, finalHash);\n    }\n\n    public Repository seedRepository(HostedRepository hostedRepository, boolean allowStale) throws IOException {\n        var hostedRepositoryInstance = new HostedRepositoryInstance(hostedRepository);\n        var repo = hostedRepositoryInstance.seedRepository(allowStale);\n        if (!allowStale) {\n            fetchWithRetry(repo, hostedRepository.authenticatedUrl());\n        }\n        return repo;\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/LabelConfiguration.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.util.Set;\n\npublic interface LabelConfiguration {\n    Set<String> label(Set<Path> changes);\n    Set<String> allowed();\n    boolean isAllowed(String s);\n    Set<String> upgradeLabelsToGroups(Set<String> labels);\n    /**\n     * Returns the set of groups that this label belongs to.\n     */\n    Set<String> groupLabels(String label);\n    /**\n     * Returns the set of labels belongs to this group label\n     */\n    List<String> labelsInGroup(String label);\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/LabelConfigurationHostedRepository.java",
    "content": "/*\n * Copyright (c) 2020, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.openjdk.skara.json.JSON;\n\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.util.Set;\n\npublic class LabelConfigurationHostedRepository implements LabelConfiguration {\n    private final HostedRepository repository;\n    private final String ref;\n    private final String filename;\n\n    private String latestFileContents = \"\";\n    private LabelConfiguration latestParsedConfiguration;\n\n    private LabelConfigurationHostedRepository(HostedRepository repository, String ref, String filename) {\n        this.repository = repository;\n        this.ref = ref;\n        this.filename = filename;\n    }\n\n    public static LabelConfiguration from(HostedRepository repository, String ref, String filename) {\n        return new LabelConfigurationHostedRepository(repository, ref, filename);\n    }\n\n    private LabelConfiguration labelConfiguration() {\n        var contents = repository.fileContents(filename, ref)\n                .orElseThrow(() -> new RuntimeException(\"Could not find \" + filename + \" on ref \" + ref + \" in repo \" + repository.name()));\n        if (!contents.equals(latestFileContents)) {\n            latestFileContents = contents;\n            var json = JSON.parse(contents);\n            latestParsedConfiguration = LabelConfigurationJson.from(json);\n        }\n        return latestParsedConfiguration;\n    }\n\n    @Override\n    public Set<String> label(Set<Path> changes) {\n        return labelConfiguration().label(changes);\n    }\n\n    @Override\n    public Set<String> allowed() {\n        return labelConfiguration().allowed();\n    }\n\n    @Override\n    public boolean isAllowed(String s) {\n        return labelConfiguration().isAllowed(s);\n    }\n\n    @Override\n    public Set<String> upgradeLabelsToGroups(Set<String> labels) {\n        return labelConfiguration().upgradeLabelsToGroups(labels);\n    }\n\n    @Override\n    public Set<String> groupLabels(String label) {\n        return labelConfiguration().groupLabels(label);\n    }\n\n    @Override\n    public List<String> labelsInGroup(String label) {\n        return labelConfiguration().labelsInGroup(label);\n    }\n}\n\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/LabelConfigurationJson.java",
    "content": "/*\n * Copyright (c) 2020, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.openjdk.skara.json.*;\n\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class LabelConfigurationJson implements LabelConfiguration {\n    private final Map<String, List<Pattern>> matchers;\n    private final Map<String, List<String>> groups;\n    private final Set<String> extra;\n    private final Set<String> allowed;\n\n    private LabelConfigurationJson(Map<String, List<Pattern>> matchers, Map<String, List<String>> groups, Set<String> extra) {\n        this.matchers = Collections.unmodifiableMap(matchers);\n        this.groups = Collections.unmodifiableMap(groups);\n        this.extra = Collections.unmodifiableSet(extra);\n\n        var allowed = new HashSet<String>();\n        allowed.addAll(matchers.keySet());\n        allowed.addAll(groups.keySet());\n        allowed.addAll(extra);\n        this.allowed = Collections.unmodifiableSet(allowed);\n    }\n\n    public static class Builder {\n        private final Map<String, List<Pattern>> matchers = new HashMap<>();\n        private final Map<String, List<String>> groups = new HashMap<>();\n        private final Set<String> extra = new HashSet<>();\n\n        public Builder addMatchers(String label, List<Pattern> matchers) {\n            this.matchers.put(label, matchers);\n            return this;\n        }\n\n        public Builder addGroup(String label, List<String> members) {\n            groups.put(label, members);\n            return this;\n        }\n\n        public Builder addExtra(String label) {\n            extra.add(label);\n            return this;\n        }\n\n        public LabelConfiguration build() {\n            return new LabelConfigurationJson(matchers, groups, extra);\n        }\n    }\n\n    public static Builder builder() {\n        return new Builder();\n    }\n\n    public static LabelConfiguration from(JSONValue json) {\n        var builder = builder();\n        if (json.contains(\"matchers\")) {\n            var fields = json.get(\"matchers\").fields();\n            var matchers = fields.stream()\n                                 .collect(Collectors.toMap(JSONObject.Field::name,\n                                                           field -> field.value()\n                                                                         .stream()\n                                                                         .map(JSONValue::asString)\n                                                                         .map(s -> Pattern.compile(\"^\" + s, Pattern.CASE_INSENSITIVE))\n                                                                         .collect(Collectors.toList())));\n            matchers.forEach(builder::addMatchers);\n        }\n        if (json.contains(\"groups\")) {\n            var fields = json.get(\"groups\").fields();\n            var groups = fields.stream()\n                               .collect(Collectors.toMap(JSONObject.Field::name,\n                                                         field -> field.value()\n                                                                       .stream()\n                                                                       .map(JSONValue::asString)\n                                                                       .collect(Collectors.toList())));\n            groups.forEach(builder::addGroup);\n        }\n        if (json.contains(\"extra\")) {\n            var extra = json.get(\"extra\").stream()\n                            .map(JSONValue::asString)\n                            .collect(Collectors.toList());\n            extra.forEach(builder::addExtra);\n        }\n        return builder.build();\n    }\n\n    public static LabelConfiguration fromHostedRepositoryFile(HostedRepository repository, String ref, String filename) {\n        var jsonText = repository.fileContents(filename, ref).orElseThrow(() ->\n                new RuntimeException(\"Could not find \" + filename + \" on ref \" + ref + \" in repo\" + repository.name())\n        );\n        var json = JSON.parse(jsonText);\n        return from(json);\n    }\n\n    public Set<String> label(Set<Path> changes) {\n        var labels = new HashSet<String>();\n        for (var file : changes) {\n            for (var label : matchers.entrySet()) {\n                for (var pattern : label.getValue()) {\n                    var matcher = pattern.matcher(file.toString());\n                    if (matcher.find()) {\n                        labels.add(label.getKey());\n                        break;\n                    }\n                }\n            }\n        }\n\n        return upgradeLabelsToGroups(labels);\n    }\n\n    public Set<String> allowed() {\n        return allowed;\n    }\n\n    public boolean isAllowed(String s) {\n        return allowed.contains(s);\n    }\n\n    @Override\n    public Set<String> upgradeLabelsToGroups(Set<String> labels) {\n        var ret = new HashSet<>(labels);\n        // If the current labels matches at least two members of a group, use the group\n        for (var group : groups.entrySet()) {\n            var count = 0;\n            for (var groupEntry : group.getValue()) {\n                if (ret.contains(groupEntry)) {\n                    count++;\n                    if (count == 2) {\n                        ret.add(group.getKey());\n                        break;\n                    }\n                }\n            }\n        }\n\n        // Finally remove all group members for any group that has been matched (note that a group can\n        // also have individual rules and be matched in the first step).\n        for (var group : groups.entrySet()) {\n            if (ret.contains(group.getKey())) {\n                ret.removeAll(group.getValue());\n            }\n        }\n        return ret;\n    }\n\n    public Set<String> groupLabels(String label) {\n        var ret = new HashSet<String>();\n        for (var group : groups.entrySet()) {\n            if (group.getValue().contains(label)) {\n                ret.add(group.getKey());\n            }\n        }\n        return ret;\n    }\n\n    public List<String> labelsInGroup(String label) {\n       for(var group : groups.entrySet()) {\n           if (group.getKey().equals(label)) {\n               return group.getValue();\n           }\n       }\n       return List.of();\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/MemberState.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\n/**\n * Forge group member states\n */\npublic enum MemberState {\n    ACTIVE,\n    PENDING,\n    MISSING\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/PreIntegrations.java",
    "content": "/*\n * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.util.*;\n\npublic class PreIntegrations {\n    public static Optional<String> dependentPullRequestId(PullRequest pr) {\n        if (isPreintegrationBranch(pr.targetRef())) {\n            var depStart = pr.targetRef().lastIndexOf(\"/\");\n            if (depStart == -1) {\n                throw new IllegalStateException(\"Cannot parse target ref: \" + pr.targetRef());\n            }\n            var depId = pr.targetRef().substring(depStart + 1);\n            return Optional.of(depId);\n        } else {\n            return Optional.empty();\n        }\n    }\n\n    public static String preIntegrateBranch(PullRequest pr) {\n        return \"pr/\" + pr.id();\n    }\n\n    public static Collection<PullRequest> retargetDependencies(PullRequest pr) {\n        var ret = new ArrayList<PullRequest>();\n        var dependentRef = preIntegrateBranch(pr);\n\n        var candidates = pr.repository().openPullRequests();\n        for (var candidate : candidates) {\n            if (candidate.targetRef().equals(dependentRef)) {\n                candidate.setTargetRef(pr.targetRef());\n                ret.add(candidate);\n            }\n        }\n        return ret;\n    }\n\n    public static boolean isPreintegrationBranch(String name) {\n        return name.startsWith(\"pr/\");\n    }\n\n    public static String realTargetRef(PullRequest pr) {\n        Optional<String> idOpt = dependentPullRequestId(pr);\n        return idOpt.isEmpty() ? pr.targetRef() : realTargetRef(pr.repository().pullRequest(idOpt.get()));\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/PullRequest.java",
    "content": "/*\n * Copyright (c) 2018, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.vcs.Diff;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.net.URI;\nimport java.time.ZonedDateTime;\nimport java.util.*;\n\npublic interface PullRequest extends Issue {\n    HostedRepository repository();\n\n    /**\n     * List of reviews.\n     * @return\n     */\n    List<Review> reviews();\n\n    /**\n     * Adds a review with the given verdict.\n     */\n    void addReview(Review.Verdict verdict, String body);\n\n    /**\n     * Updates the comment body of a review.\n     */\n    void updateReview(String id, String body);\n\n    /**\n     * Add a file specific comment.\n     * @param hash\n     * @param path\n     * @param line\n     * @param body\n     * @return\n     */\n    ReviewComment addReviewComment(Hash base, Hash hash, String path, int line, String body);\n\n    /**\n     * Reply to a file specific comment.\n     * @param parent\n     * @param body\n     * @return\n     */\n    ReviewComment addReviewCommentReply(ReviewComment parent, String body);\n\n    /**\n     * Get all file specific comments.\n     * @return\n     */\n    List<ReviewComment> reviewComments();\n\n    /**\n     * Get all file specific comments but potentially without file location data.\n     * This may save computation and I/O time if constructing that data is expensive.\n     * @return\n     */\n    default List<? extends Comment> reviewCommentsAsComments() {\n        return reviewComments();\n    }\n\n    /**\n     * Hash of the current head of the request.\n     * @return\n     */\n    Hash headHash();\n\n    /**\n     * URI to the current head of the request.\n     * @return\n     */\n    URI headUrl();\n\n    /**\n     * Returns the name of the ref used for fetching the pull request.\n     * @return\n     */\n    String fetchRef();\n\n    /**\n     * Returns the name of the ref the request is created from.\n     * @return\n     */\n    String sourceRef();\n\n    /**\n     * Returns the repository the request is created from.\n     * @return\n     */\n    Optional<HostedRepository> sourceRepository();\n\n    /**\n     * Returns the name of the ref the request is intended to be merged into.\n     * @return\n     */\n    String targetRef();\n\n    /**\n     * Returns a list of all targetRef change events.\n     * @return\n     */\n    List<ReferenceChange> targetRefChanges();\n\n    /**\n     * List of completed checks on the given hash.\n     * @return\n     */\n    Map<String, Check> checks(Hash hash);\n\n    /** Returns a link to the patch/diff file\n     * @return\n     */\n    URI diffUrl();\n\n    /** Returns a diff of the changes between PR HEAD and target branch.\n     * @return\n     */\n    Diff diff();\n\n    /**\n     * Creates a new check.\n     * @param check\n     */\n    void createCheck(Check check);\n\n    /**\n     * Updates an existing check.\n     * @param check\n     */\n    void updateCheck(Check check);\n\n    /**\n     * Returns a link that will lead to the list of changes done in the request.\n     */\n    URI changeUrl();\n\n    /**\n     * Returns a link that will lead to the list of changes with the specified base.\n     */\n    URI changeUrl(Hash base);\n\n    URI reviewCommentUrl(ReviewComment reviewComment);\n\n    URI reviewUrl(Review review);\n\n    /**\n     * Returns true if the request is in draft mode.\n     * @return\n     */\n    boolean isDraft();\n    void makeNotDraft();\n\n    /**\n     * Return the last time the pull request was converted to draft.\n     * If the pull request was created as draft, return the created time of the pull request.\n     * If the pull request was always ready for review and never converted to draft, return empty.\n     * If the restful api doesn't support draft pull request, return empty.\n     * Note: if the pull request was created as draft, but later converted to ready\n     *  and didn't convert to draft again, this method will return empty.\n     */\n    Optional<ZonedDateTime> lastMarkedAsDraftTime();\n\n    Optional<ZonedDateTime> labelAddedAt(String label);\n\n    /**\n     * Update the ref the request is intended to be merged into.\n     * @return\n     */\n    void setTargetRef(String targetRef);\n\n    URI filesUrl(Hash hash);\n\n    /**\n     * Returns true if this PullRequest represents the same pull request as the other.\n     */\n    default boolean isSame(PullRequest other) {\n        return id().equals(other.id()) && repository().isSame(other.repository());\n    }\n\n    /**\n     * Return the last time something was force pushed while not in draft state.\n     * If there is no force-push in pull request or the restful api doesn't\n     * support force-push, return empty.\n     */\n    Optional<ZonedDateTime> lastForcePushTime();\n\n    /**\n     * Return the commit hash if the pull request was integrated.\n     */\n    Optional<Hash> findIntegratedCommitHash();\n\n    default Optional<Hash> findIntegratedCommitHash(List<String> userIds) {\n        Pattern pushedPattern = Pattern.compile(\"Pushed as commit ([a-f0-9]{40})\\\\.\");\n        if (labelNames().contains(\"integrated\")) {\n            return comments().stream()\n                    .filter(comment -> userIds.contains(comment.author().id()))\n                    .map(Comment::body)\n                    .map(pushedPattern::matcher)\n                    .filter(Matcher::find)\n                    .map(m -> m.group(1))\n                    .map(Hash::new)\n                    .findAny();\n        }\n        return Optional.empty();\n    }\n\n    /**\n     * Return the comment message about the commit hash.\n     */\n    static String commitHashMessage(Hash hash) {\n        return hash != null ? \"Pushed as commit \" + hash.hex() + \".\" : \"\";\n    }\n\n    /**\n     * Returns an object that represents a complete snapshot of this pull request.\n     * Used for detecting if anything has changed between two snapshots.\n     */\n    Object snapshot();\n\n    /**\n     * Returns the last time of the pull request touched by user\n     * Valid Touch includes \"mark as ready\", \"convert to draft\", \"reopen\", \"commit\"\n     */\n    ZonedDateTime lastTouchedTime();\n\n    /**\n     * Helper method for implementations of this interface. Creates a new list\n     * of Review objects with the targetRef field updated to match the target\n     * ref change events. Ideally this method should have been part of a common\n     * super class, but there isn't one.\n     */\n    static List<Review> calculateReviewTargetRefs(List<Review> reviews, List<ReferenceChange> events) {\n        if (events.isEmpty()) {\n            return reviews;\n        }\n        var sortedEvents = events.stream()\n                .sorted(Comparator.comparing(ReferenceChange::at))\n                .toList();\n        var lastTargetRef = sortedEvents.getLast().to();\n        return reviews.stream().map(orig -> {\n                    for (var event : sortedEvents) {\n                        if (event.at().isAfter(orig.createdAt())\n                                && !PreIntegrations.isPreintegrationBranch(event.from())) {\n                            return orig.withTargetRef(event.from());\n                        }\n                    }\n                    if (orig.targetRef().equals(lastTargetRef)) {\n                        return orig;\n                    } else {\n                        return orig.withTargetRef(lastTargetRef);\n                    }\n                })\n                .toList();\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/PullRequestBody.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.net.URI;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\npublic class PullRequestBody {\n    private final String bodyText;\n    private final List<URI> issues;\n    private final List<String> contributors;\n\n    private PullRequestBody(String bodyText, List<URI> issues, List<String> contributors) {\n        this.bodyText = bodyText;\n        this.issues = issues;\n        this.contributors = contributors;\n    }\n\n    public String bodyText() {\n        return bodyText;\n    }\n\n    public List<URI> issues() {\n        return issues;\n    }\n\n    public List<String> contributors() {\n        return contributors;\n    }\n\n    public static PullRequestBody parse(PullRequest pr) {\n        return parse(Arrays.asList(pr.body().split(\"\\n\")));\n    }\n\n    public static PullRequestBody parse(String body) {\n        return parse(Arrays.asList(body.split(\"\\n\")));\n    }\n\n    public static PullRequestBody parse(List<String> lines) {\n        var issues = new ArrayList<URI>();\n        var contributors = new ArrayList<String>();\n        var bodyText = new StringBuilder();\n        var inBotComment = false;\n\n        var i = 0;\n        while (i < lines.size()) {\n            var line = lines.get(i);\n            if (line.startsWith(\"### Issue\")) {\n                i++;\n                while (i < lines.size()) {\n                    line = lines.get(i);\n                    if (!line.startsWith(\" * \")) {\n                        break;\n                    }\n                    var startUrl = line.indexOf('(');\n                    var endUrl = line.indexOf(')', startUrl);\n                    if (startUrl != -1 && endUrl != -1) {\n                        var url = URI.create(line.substring(startUrl + 1, endUrl));\n                        issues.add(url);\n                    }\n                    i++;\n                }\n            }\n            if (line.startsWith(\"### Contributors\")) {\n                i++;\n                while (i < lines.size()) {\n                    line = lines.get(i);\n                    if (!line.startsWith(\" * \")) {\n                        break;\n                    }\n                    var contributor = line.substring(3).replace(\"`\",\"\");\n                    contributors.add(contributor);\n                    i++;\n                }\n            } else {\n                i++;\n            }\n            if (line.startsWith(\"<!-- Anything below this marker will be\")) {\n                inBotComment = true;\n            }\n            if (!inBotComment) {\n                bodyText.append(line).append(\"\\n\");\n            }\n        }\n\n        return new PullRequestBody(bodyText.toString().trim(), issues, contributors);\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/PullRequestPoller.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.ZonedDateTime;\nimport java.util.Comparator;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\nimport org.openjdk.skara.issuetracker.Issue;\n\n/**\n * A PullRequestPoller handles querying for new and updated pull requests. It\n * guarantees that no pull request updates at the forge are missed and avoids\n * returning the same update multiple times as much as possible.\n * <p>\n * On the first call, all open PRs, and if configured, non-open PRs (up to a\n * limit), are returned. After that only updated PRs should be included.\n * <p>\n * After each call for updated pull requests, the result needs to be\n * acknowledged once it has been processed by the caller using the\n * lastBatchHandled method. Failing to acknowledge makes the next call include\n * everything from the last call again. This helps to avoid missing any updates\n * due to errors. Calling the retry/quarantine methods before lastBatchHandled\n * for any particular PR will cause that PR to be lost.\n * <p>\n * In addition to this, it's also possible to schedule PRs for retries with\n * or without quarantine. A regular retry will not block the same PR if it\n * gets returned by the regular query, but doing so will cancel the future\n * retry. A quarantine type retry will completely block that PR until the\n * quarantine is lifted. In both cases, the actual object returned will be\n * the same one provided in the retry call, unless a newer instance has\n * been returned by a query since then.\n */\npublic class PullRequestPoller {\n\n    private static final Logger log = Logger.getLogger(PullRequestPoller.class.getName());\n\n    // The max age for closed PRs for the initial query, and the furthest\n    // back subsequent queries will ever search.\n    private static final Duration UPDATED_AT_QUERY_LIMIT = Duration.ofDays(7);\n\n    private final HostedRepository repository;\n    // Negative query padding is used to compensate for the forge only updating\n    // timestamps on pull requests once for set minimum duration.\n    private final Duration negativeQueryPadding;\n    // Positive query padding is used to work around timestamp queries being\n    // inclusive down to a certain time resolution.\n    private final Duration positiveQueryPadding;\n    private final boolean includeClosed;\n\n    private record PullRequestRetry(PullRequest pr, Instant when) {}\n    private final Map<String, PullRequestRetry> retryMap = new HashMap<>();\n    private final Map<String, PullRequestRetry> quarantineMap = new HashMap<>();\n\n    /**\n     * This record represents all the query results data needed to correctly figure\n     * out if future results have been updated or not.\n     */\n    record QueryResult(Map<String, PullRequest> pullRequests, Map<String, Object> comparisonSnapshots,\n                       ZonedDateTime maxUpdatedAt, Instant afterQuery, List<PullRequest> result,\n                       /*\n                        * When enough time has passed since the last time we returned results, applying\n                        * negative padding to the updatedAt query parameter is no longer needed. This\n                        * is indicated using this boolean.\n                        */\n                       boolean negativePaddingNeeded) {}\n    private QueryResult current;\n    private QueryResult prev;\n\n    public PullRequestPoller(HostedRepository repository, boolean includeClosed) {\n        this.repository = repository;\n        this.includeClosed = includeClosed;\n        negativeQueryPadding = repository.forge().minTimeStampUpdateInterval();\n        positiveQueryPadding = repository.forge().timeStampQueryPrecision();\n    }\n\n    /**\n     * The main API method. Call this to get updated PRs. When done processing the results\n     * call lastBatchHandled() to acknowledge that all the returned PRs have been handled\n     * and should not be included in the next call of this method.\n     */\n    public List<PullRequest> updatedPullRequests() {\n        var beforeQuery = Instant.now();\n        List<PullRequest> prs = queryPullRequests();\n        var afterQuery = Instant.now();\n        log.info(\"Found \" + prs.size() + \" updated pull requests before filtering for \" + repository.name()\n                + \" [\" + prs.stream().map(Issue::id).collect(Collectors.joining(\", \")) + \"]\");\n\n        // Convert the query result into a map\n        var pullRequestMap = prs.stream().collect(Collectors.toMap(PullRequest::id, pr -> pr));\n\n        // Find the max updatedAt value in the result set. Fall back on the previous\n        // value (happens if no results were returned), or null (if no results have\n        // been found at all so far).\n        var maxUpdatedAtLimit = ZonedDateTime.now().minus(UPDATED_AT_QUERY_LIMIT);\n        var maxUpdatedAt = prs.stream()\n                .map(PullRequest::updatedAt)\n                .filter(updatedAt -> updatedAt.isAfter(maxUpdatedAtLimit))\n                .max(Comparator.naturalOrder())\n                .orElseGet(() -> prev != null ? prev.maxUpdatedAt : maxUpdatedAtLimit);\n\n        // Save the current comparisonSnapshots\n        var comparisonSnapshots = fetchComparisonSnapshots(prs, maxUpdatedAt);\n\n        // Filter the results\n        var filtered = prs.stream()\n                .filter(this::isUpdated)\n                .toList();\n\n        log.info(\"Found \" + filtered.size() + \" updated pull requests after filtering for \" + repository.name()\n                + \" [\" + filtered.stream().map(Issue::id).collect(Collectors.joining(\", \")) + \"]\");\n\n        // If nothing was left after filtering, update the paddingNeeded state if enough time\n        // has passed since last we found something.\n        boolean negativePaddingNeeded = true;\n        if (filtered.isEmpty()) {\n            if (prev != null) {\n                // The afterQuery value that we save should be the time when we last\n                // found something after filtering.\n                afterQuery = prev.afterQuery;\n                if (prev.afterQuery.isBefore(beforeQuery.minus(negativeQueryPadding)\n                        .minus(positiveQueryPadding))) {\n                    negativePaddingNeeded = false;\n                }\n            }\n        }\n\n        var withRetries = addRetries(filtered);\n\n        var result = processQuarantined(withRetries);\n\n        // Save the state of the current query results\n        current = new QueryResult(pullRequestMap, comparisonSnapshots, maxUpdatedAt, afterQuery, result, negativePaddingNeeded);\n\n        log.info(\"Found \" + result.size() + \" updated pull requests for \" + repository.name()\n                + \" [\" + result.stream().map(Issue::id).collect(Collectors.joining(\", \")) + \"]\");\n        return result;\n    }\n\n    /**\n     * After calling updatedPullRequests(), this method must be called to acknowledge\n     * that all the PRs returned have been handled. If not, the previous results will be\n     * included in the next call to updatedPullRequests() again.\n     * <p>\n     * This method must be called before any retry/quarantine method is called for a pr\n     * returned by the last updatedPullRequest call, otherwise retries may be lost.\n     * <p>\n     * The typical pattern is to call this last in the getPeriodicItems/run method of a\n     * bot or WorkItem, before any generated WorkItems are published (by being returned\n     * to the bot runner).\n     */\n    public synchronized void lastBatchHandled() {\n        if (current != null) {\n            prev = current;\n            current = null;\n            // Remove any returned PRs from the retry/quarantine sets\n            prev.result.forEach(pr -> retryMap.remove(pr.id()));\n            prev.result.forEach(pr -> quarantineMap.remove(pr.id()));\n        }\n    }\n\n    /**\n     * If handling of a pull request fails, call this to have it be included in the next\n     * update, regardless of if it was updated or not.\n     * @param pr PullRequest to retry\n     */\n    public synchronized void retryPullRequest(PullRequest pr) {\n        retryPullRequest(pr, Instant.MIN);\n    }\n\n    /**\n     * Schedules a pull request to be included in the next update that happens after a\n     * certain time, unless it is updated before that. Can be used to throttle retries.\n     * @param pr PullRequest to retry\n     * @param at Time at which to process it\n     */\n    public synchronized void retryPullRequest(PullRequest pr, Instant at) {\n        retryMap.put(pr.id(), new PullRequestRetry(pr, at));\n    }\n\n    /**\n     * Schedules a pull request to be included in the next update that happens after a\n     * quarantine period has passed. If a quarantined pull request is returned by a\n     * query, it will be removed from the result set until the quarantine time has\n     * passed.\n     * @param pr PullRequest to quarantine\n     * @param until Time at which the quarantine is lifted\n     */\n    public synchronized void quarantinePullRequest(PullRequest pr, Instant until) {\n        quarantineMap.put(pr.id(), new PullRequestRetry(pr, until));\n    }\n\n    /**\n     * Queries the repository for pull requests. On the first round (or until any\n     * results have been received), get all pull requests. After that limit the\n     * results using the maxUpdatedAt value from the previous results. Use padding\n     * if needed to guarantee that we never miss an update.\n     */\n    private List<PullRequest> queryPullRequests() {\n        if (prev == null || prev.maxUpdatedAt == null) {\n            if (includeClosed) {\n                log.fine(\"Fetching all open and recent closed pull requests for \" + repository.name());\n                // We need to guarantee that all open PRs are always included in the first round.\n                // The pullRequests(ZonedDateTime) call has a size limit, so may leave some out.\n                // There may also be open PRs that haven't been updated since the closed age limit.\n                var openPrs = repository.openPullRequests();\n                var allPrs = repository.pullRequestsAfter(ZonedDateTime.now().minus(UPDATED_AT_QUERY_LIMIT));\n                return Stream.concat(openPrs.stream(), allPrs.stream().filter(pr -> !pr.isOpen())).toList();\n            } else {\n                log.fine(\"Fetching all open pull requests for \" + repository.name());\n                return repository.openPullRequests();\n            }\n        } else {\n            var queryUpdatedAt = prev.negativePaddingNeeded\n                    ? prev.maxUpdatedAt.minus(negativeQueryPadding) : prev.maxUpdatedAt.plus(positiveQueryPadding);\n            if (includeClosed) {\n                log.fine(\"Fetching open and closed pull requests updated after \" + queryUpdatedAt + \" for \" + repository.name());\n                return repository.pullRequestsAfter(queryUpdatedAt);\n            } else {\n                log.fine(\"Fetching open pull requests updated after \" + queryUpdatedAt + \" for \" + repository.name());\n                return repository.openPullRequestsAfter(queryUpdatedAt);\n            }\n        }\n    }\n\n    private Map<String, Object> fetchComparisonSnapshots(List<PullRequest> prs, ZonedDateTime maxUpdatedAt) {\n        return prs.stream()\n                .filter(pr -> !pr.updatedAt().isBefore(maxUpdatedAt.minus(negativeQueryPadding)))\n                .collect(Collectors.toMap(Issue::id, PullRequest::snapshot));\n    }\n\n    /**\n     * Evaluates if a PR has been updated since the previous query result.\n     * First checks updatedAt and then the comparisonSnapshot of the PR if\n     * present in the prev data.\n     */\n    private boolean isUpdated(PullRequest pr) {\n        if (prev == null) {\n            return true;\n        }\n        var prPrev = prev.pullRequests.get(pr.id());\n        if (prPrev == null || pr.updatedAt().isAfter(prPrev.updatedAt())) {\n            return true;\n        }\n        if (!pr.snapshot().equals(prev.comparisonSnapshots.get(pr.id()))) {\n            return true;\n        }\n        return false;\n    }\n\n    /**\n     * Returns a list of all prs with retries added.\n     */\n    private synchronized List<PullRequest> addRetries(List<PullRequest> prs) {\n        if (retryMap.isEmpty()) {\n            return prs;\n        } else {\n            // Find the retries that have passed their at time.\n            var now = Instant.now();\n            var retries = retryMap.values().stream()\n                    .filter(prRetry -> prRetry.when.isBefore(now))\n                    .filter(prRetry -> prs.stream().noneMatch(pr -> pr.id().equals(prRetry.pr.id())))\n                    .map(PullRequestRetry::pr)\n                    .toList();\n            if (retries.isEmpty()) {\n                return prs;\n            } else {\n                return Stream.concat(prs.stream(), retries.stream()).toList();\n            }\n        }\n    }\n\n    /**\n     * Returns a list of all prs with still quarantined prs removed and newly lifted\n     * prs added.\n     */\n    private synchronized List<PullRequest> processQuarantined(List<PullRequest> prs) {\n        if (quarantineMap.isEmpty()) {\n            return prs;\n        } else {\n            var now = Instant.now();\n            // Replace the PR instances in the quarantineMap with any freshly fetched PRs.\n            // By doing this, we will always return the most up-to-date version of the PR\n            // that we have seen so far.\n            prs.forEach(pr -> {\n                if (quarantineMap.containsKey(pr.id())) {\n                    quarantineMap.put(pr.id(), new PullRequestRetry(pr, quarantineMap.get(pr.id()).when));\n                }\n            });\n            // Find all quarantined PRs that are now past the time\n            var pastQuarantine = quarantineMap.values().stream()\n                    .filter(prRetry -> prRetry.when.isBefore(now))\n                    .filter(prRetry -> prs.stream().noneMatch(pr -> pr.id().equals(prRetry.pr.id())))\n                    .map(PullRequestRetry::pr)\n                    .toList();\n            // Find all still quarantined PRs\n            var stillQuarantined = quarantineMap.values().stream()\n                    .filter(prRetry -> !prRetry.when.isBefore(now))\n                    .map(prRetry -> prRetry.pr.id())\n                    .collect(Collectors.toSet());\n            return Stream.concat(\n                            prs.stream().filter(pr -> !stillQuarantined.contains(pr.id())),\n                            pastQuarantine.stream())\n                    .toList();\n        }\n    }\n\n    // Expose the query results to tests\n    QueryResult getCurrentQueryResult() {\n        return current;\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/PullRequestUpdateCache.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.openjdk.skara.forge.gitlab.GitLabMergeRequest;\n\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.logging.Logger;\n\npublic class PullRequestUpdateCache {\n    private final Map<String, ZonedDateTime> lastUpdates = new HashMap<>();\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.host\");\n\n    public synchronized boolean needsUpdate(PullRequest pr) {\n        // GitLab CE does not update this field on events such as adding an award\n        if (pr instanceof GitLabMergeRequest) {\n            return true;\n        }\n\n        var uniqueId = pr.webUrl().toString();\n        var update = pr.updatedAt();\n\n        if (!lastUpdates.containsKey(uniqueId)) {\n            lastUpdates.put(uniqueId, update);\n            return true;\n        }\n        var lastUpdate = lastUpdates.get(uniqueId);\n        if (lastUpdate.isBefore(update)) {\n            lastUpdates.put(uniqueId, update);\n            return true;\n        }\n        log.info(\"Skipping update for \" + pr.repository().name() + \"#\" + pr.id());\n        return false;\n    }\n\n    public synchronized void invalidate(PullRequest pr) {\n        var uniqueId = pr.webUrl().toString();\n        lastUpdates.remove(uniqueId);\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/PullRequestUtils.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.time.format.DateTimeFormatter;\nimport java.util.logging.Level;\nimport java.util.logging.Logger;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.file.Path;\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class PullRequestUtils {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.forge\");\n\n    private static Hash commitSquashed(Repository localRepo, Hash finalHead, Author author, Author committer, String commitMessage) throws IOException {\n        return localRepo.commit(commitMessage, author.name(), author.email(), ZonedDateTime.now(),\n                                committer.name(), committer.email(), ZonedDateTime.now(), List.of(targetHash(localRepo)), localRepo.tree(finalHead));\n    }\n\n    public final static Pattern mergeSourcePattern = Pattern.compile(\"^Merge ([-/.\\\\w:+]+)$\");\n    private final static Pattern hashSourcePattern = Pattern.compile(\"[0-9a-fA-F]{6,40}\");\n\n    private static Optional<Hash> fetchRef(Repository localRepo, URI uri, String ref) {\n        // Just a plain name - is this a branch?\n        try {\n            return localRepo.fetch(uri, \"+\" + ref + \":refs/heads/merge_source\", false);\n        } catch (IOException e) {\n            // Ignored\n        }\n\n        // Perhaps it is an actual tag object - it cannot be fetched to a branch ref\n        try {\n            return localRepo.fetch(uri, \"+\" + ref + \":refs/tags/merge_source_tag\", false);\n        } catch (IOException e) {\n            // Ignored\n        }\n\n        return Optional.empty();\n    }\n\n    private static Hash fetchMergeSource(PullRequest pr, Repository localRepo) throws IOException, CommitFailure {\n        var sourceMatcher = mergeSourcePattern.matcher(pr.title());\n        if (!sourceMatcher.matches()) {\n            throw new CommitFailure(\"Could not determine the source for this merge. A Merge PR title must be specified in the format: `\" +\n                                            mergeSourcePattern + \"` to allow verification of the merge contents.\");\n        }\n        var source = sourceMatcher.group(1);\n\n        // A hash in the PRs local history can also be a valid source\n        var hashSourceMatcher = hashSourcePattern.matcher(source);\n        if (hashSourceMatcher.matches()) {\n            var hash = localRepo.resolve(source);\n            if (hash.isPresent()) {\n                // A valid merge source hash cannot be an ancestor of the target branch (if so it would not need to be merged)\n                var prTargetHash = PullRequestUtils.targetHash(localRepo);\n                if (!localRepo.isAncestor(hash.get(), prTargetHash)) {\n                    return hash.get();\n                }\n            }\n        }\n\n        String repoName;\n        String ref;\n        if (!source.contains(\":\")) {\n            // Try to fetch the source as a name of a ref (branch or tag)\n            var hash = fetchRef(localRepo, pr.repository().authenticatedUrl(), source);\n            if (hash.isPresent()) {\n                return hash.get();\n            }\n\n            // Only valid option now is a repository - use default ref\n            repoName = source;\n            ref = Branch.defaultFor(VCS.GIT).name();\n        } else {\n            repoName = source.split(\":\", 2)[0];\n            ref = source.split(\":\", 2)[1];\n        }\n\n        // If the repository name is unqualified we assume it is a sibling\n        if (!repoName.contains(\"/\")) {\n            repoName = Path.of(pr.repository().name()).resolveSibling(repoName).toString();\n        }\n\n        // Validate the repository\n        var sourceRepo = pr.repository().forge().repository(repoName);\n        if (sourceRepo.isEmpty()) {\n            throw new CommitFailure(\"Could not find project `\" + repoName + \"` - check that it is correct.\");\n        }\n\n        var hash = fetchRef(localRepo, sourceRepo.get().authenticatedUrl(), ref);\n        if (hash.isPresent()) {\n            return hash.get();\n        } else {\n            throw new CommitFailure(\"Could not find the branch or tag `\" + ref + \"` in the project `\" + repoName + \"` - check that it is correct.\");\n        }\n    }\n\n    private static Hash findSourceHash(PullRequest pr, Repository localRepo, List<CommitMetadata> commits) throws IOException, CommitFailure {\n        if (commits.size() < 1) {\n            throw new CommitFailure(\"A merge PR must contain at least one commit that is not already present in the target.\");\n        }\n\n        // Fetch the source\n        var sourceHead = fetchMergeSource(pr, localRepo);\n\n        // Ensure that the source and the target are related\n        localRepo.mergeBaseOptional(targetHash(localRepo), sourceHead)\n                .orElseThrow(() -> new CommitFailure(\"The target and the source branches do not share common history - cannot merge them.\"));\n\n        // Find the most recent commit from the merge source not present in the target\n        var sourceHash = localRepo.mergeBase(pr.headHash(), sourceHead);\n        var commitHashes = commits.stream()\n                                  .map(CommitMetadata::hash)\n                                  .collect(Collectors.toSet());\n        if (!commitHashes.contains(sourceHash)) {\n            throw new CommitFailure(\"A merge PR must contain at least one commit from the source branch that is not already present in the target.\");\n        }\n\n        return sourceHash;\n    }\n\n    private static Hash commitMerge(PullRequest pr, Repository localRepo, Hash finalHead, Author author, Author committer, String commitMessage) throws IOException, CommitFailure {\n        var commits = localRepo.commitMetadata(baseHash(pr, localRepo), finalHead);\n        var sourceHash = findSourceHash(pr, localRepo, commits);\n        var parents = List.of(localRepo.mergeBase(targetHash(localRepo), finalHead), sourceHash);\n\n        return localRepo.commit(commitMessage, author.name(), author.email(), ZonedDateTime.now(),\n                committer.name(), committer.email(), ZonedDateTime.now(), parents, localRepo.tree(finalHead));\n    }\n\n    public static Hash targetHash(Repository localRepo) throws IOException {\n        return localRepo.resolve(\"prutils_targetref\").orElseThrow(() -> new IllegalStateException(\"Must materialize PR first\"));\n    }\n\n    public static Repository materialize(HostedRepositoryPool hostedRepositoryPool, PullRequest pr, Path path) throws IOException {\n        var localRepo = hostedRepositoryPool.checkout(pr.repository(), pr.headHash().hex(), path);\n        localRepo.fetch(pr.repository().authenticatedUrl(), \"+\" + pr.targetRef() + \":prutils_targetref\", false).orElseThrow();\n        return localRepo;\n    }\n\n    public static boolean isAncestorOfTarget(Repository localRepo, Hash hash) throws IOException {\n        Optional<Hash> targetHash = localRepo.resolve(\"prutils_targetref\");\n        return localRepo.isAncestor(hash, targetHash.orElseThrow());\n    }\n\n    public static boolean isMerge(PullRequest pr) {\n        return pr.title().startsWith(\"Merge\");\n    }\n\n    public static Hash createCommit(PullRequest pr, Repository localRepo, Hash finalHead, Author author, Author committer, String commitMessage) throws IOException, CommitFailure {\n        Hash commit;\n        if (!isMerge(pr)) {\n            commit = commitSquashed(localRepo, finalHead, author, committer, commitMessage);\n        } else {\n            commit = commitMerge(pr, localRepo, finalHead, author, committer, commitMessage);\n        }\n        localRepo.checkout(commit, true);\n        return commit;\n    }\n\n    public static Hash baseHash(PullRequest pr, Repository localRepo) throws IOException {\n        return localRepo.mergeBase(targetHash(localRepo), pr.headHash());\n    }\n\n    /**\n     * Returns the set of files changed in the pull request with respect to the base hash.\n     */\n    public static Set<Path> changedFiles(PullRequest pr, Repository localRepo) throws IOException {\n        return changedFilesBetween(localRepo, baseHash(pr, localRepo), pr.headHash());\n    }\n\n    /**\n     * Returns the set of files changed in the pull request since a given commit.\n     */\n    public static Set<Path> changedFiles(PullRequest pr, Repository localRepo, Hash commitHash) throws IOException {\n        // If commitHash is not the ancestor of pr.headHash(), it means the user did force push.\n        if (!localRepo.isAncestor(commitHash, pr.headHash())) {\n            return changedFiles(pr, localRepo);\n        }\n        return changedFilesBetween(localRepo, commitHash, pr.headHash());\n    }\n\n    private static Set<Path> changedFilesBetween(Repository localRepo, Hash from, Hash to) throws IOException {\n        Set<Path> changedFiles = new HashSet<>();\n\n        Set<Path> mergeBaseToFromChangedFiles = getChangedFilesSinceMergeBase(localRepo, from);\n        Set<Path> mergeBaseToToChangedFiles = getChangedFilesSinceMergeBase(localRepo, to);\n\n        for (Path file : mergeBaseToToChangedFiles) {\n            if (!mergeBaseToFromChangedFiles.contains(file)) {\n                changedFiles.add(file);\n            }\n        }\n        return changedFiles;\n    }\n\n    private static Set<Path> getChangedFilesSinceMergeBase(Repository localRepo, Hash commit) throws IOException {\n        Set<Path> changedFiles = new HashSet<>();\n        Hash mergeBase = localRepo.mergeBase(targetHash(localRepo), commit);\n        var diff = localRepo.diff(mergeBase, commit);\n        for (var patch : diff.patches()) {\n            if (patch.status().isDeleted() || patch.status().isRenamed()) {\n                patch.source().path().ifPresent(changedFiles::add);\n            }\n            if (!patch.status().isDeleted()) {\n                patch.target().path().ifPresent(changedFiles::add);\n            }\n        }\n        return changedFiles;\n    }\n\n    public static boolean containsForeignMerge(PullRequest pr, Repository localRepo) throws IOException {\n        var baseHash = baseHash(pr, localRepo);\n        var commits = localRepo.commitMetadata(baseHash, pr.headHash());\n        var mergeParents = commits.stream()\n                                  .filter(CommitMetadata::isMerge)\n                                  .flatMap(commit -> commit.parents().stream().skip(1))\n                                  .collect(Collectors.toList());\n        for (var mergeParent : mergeParents) {\n            var mergeBase = localRepo.mergeBaseOptional(targetHash(localRepo), mergeParent);\n            if (mergeBase.isEmpty() || !mergeBase.get().equals(mergeParent)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    private static final String pullRequestMessage = \"A pull request was submitted for review.\";\n\n    /**\n     * Adds a link to a pull request as a formatted comment to an issue.\n     * @param issue Issue to add comment to\n     * @param pr PR to link to\n     */\n    public static void postPullRequestLinkComment(Issue issue, PullRequest pr) {\n        var alreadyPostedComment = issue.comments().stream()\n                .filter(comment -> comment.author().equals(issue.project().issueTracker().currentUser()))\n                .filter(comment -> comment.body().contains(pullRequestMessage) && comment.body().contains(pr.webUrl().toString()))\n                .findFirst();\n        String message = pullRequestMessage + \"\\n\" +\n                \"Branch: \" + pr.targetRef() + \"\\n\" +\n                \"URL: \" + pr.webUrl().toString() + \"\\n\" +\n                \"Date: \" + pr.createdAt().format(DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH:mm:ss +0000\"));\n        if (alreadyPostedComment.isEmpty()) {\n            issue.addComment(message);\n        } else if (!alreadyPostedComment.get().body().equals(message)) {\n            issue.updateComment(alreadyPostedComment.get().id(), message);\n        }\n    }\n\n    /**\n     * Removes a previously added comment with a link to a pull request from an issue.\n     * @param issue Issue to remove comment from\n     * @param pr PR that the comment linked to\n     */\n    public static void removePullRequestLinkComment(Issue issue, PullRequest pr) {\n        var postedComment = issue.comments().stream()\n                .filter(comment -> comment.author().equals(issue.project().issueTracker().currentUser()))\n                .filter(comment -> comment.body().contains(pullRequestMessage) && comment.body().contains(pr.webUrl().toString()))\n                .findAny();\n        postedComment.ifPresent(issue::removeComment);\n    }\n\n    /**\n     * Searches the comments of an issue for a pull request link.\n     * @param issue Issue to search\n     * @return List of all Web URI links to pull requests found in all the comments\n     */\n    public static List<URI> pullRequestCommentLink(Issue issue) {\n        return issue.comments().stream()\n                .filter(comment -> comment.author().equals(issue.project().issueTracker().currentUser()))\n                .map(PullRequestUtils::parsePullRequestComment)\n                .flatMap(Optional::stream)\n                .toList();\n\n    }\n\n    private static final Pattern PR_URL_PATTERN = Pattern.compile(\"^URL: (.*)\");\n\n    private static Optional<URI> parsePullRequestComment(Comment comment) {\n        var lines = comment.body().lines().toList();\n        if (!lines.get(0).equals(pullRequestMessage)) {\n            return Optional.empty();\n        }\n        var urlMatcher = PR_URL_PATTERN.matcher(lines.get(2));\n        if (urlMatcher.matches()) {\n            var url = urlMatcher.group(1);\n            try {\n                return Optional.of(URI.create(url));\n            } catch (IllegalArgumentException e) {\n                log.log(Level.WARNING, \"Invalid link in pull request link comment: \" + url, e);\n            }\n        }\n        return Optional.empty();\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/ReferenceChange.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.time.ZonedDateTime;\n\npublic record ReferenceChange(String from, String to, ZonedDateTime at) {\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/Review.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.util.Objects;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.time.ZonedDateTime;\nimport java.util.Optional;\n\npublic class Review {\n    private final ZonedDateTime createdAt;\n    private final HostUser reviewer;\n    private final Verdict verdict;\n    private final Hash hash;\n    private final String id;\n    private final String body;\n    private final String targetRef;\n\n    public Review(ZonedDateTime createdAt, HostUser reviewer, Verdict verdict, Hash hash, String id, String body,\n            String targetRef) {\n        this.createdAt = createdAt;\n        this.reviewer = reviewer;\n        this.verdict = verdict;\n        this.hash = hash;\n        this.id = id;\n        this.body = body;\n        this.targetRef = targetRef;\n    }\n\n    public Review withTargetRef(String targetRef) {\n        return new Review(createdAt, reviewer, verdict, hash, id, body, targetRef);\n    }\n\n    public ZonedDateTime createdAt() {\n        return createdAt;\n    }\n\n    public HostUser reviewer() {\n        return reviewer;\n    }\n\n    public Verdict verdict() {\n        return verdict;\n    }\n\n    /**\n     * The hash for the commit for which this review was created. Can be empty if the commit\n     * no longer exists.\n     */\n    public Optional<Hash> hash() {\n        return Optional.ofNullable(hash);\n    }\n\n    public String id() {\n        return id;\n    }\n\n    public Optional<String> body() {\n        return Optional.ofNullable(body);\n    }\n\n    public String targetRef() {\n        return targetRef;\n    }\n\n    public enum Verdict {\n        NONE,\n        APPROVED,\n        DISAPPROVED\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        Review review = (Review) o;\n        return id == review.id &&\n                Objects.equals(createdAt, review.createdAt) &&\n                Objects.equals(reviewer, review.reviewer) &&\n                verdict == review.verdict &&\n                Objects.equals(hash, review.hash) &&\n                Objects.equals(body, review.body) &&\n                Objects.equals(targetRef, review.targetRef);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(createdAt, reviewer, verdict, hash, id, body, targetRef);\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/ReviewComment.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.time.ZonedDateTime;\nimport java.util.*;\n\npublic class ReviewComment extends Comment {\n    private final ReviewComment parent;\n    private final String threadId;\n    private final Hash hash;\n    private final String path;\n    private final int line;\n\n    public ReviewComment(ReviewComment parent, String threadId, Hash hash, String path, int line, String id, String body, HostUser author, ZonedDateTime createdAt, ZonedDateTime updatedAt) {\n        super(id, body, author, createdAt, updatedAt);\n\n        this.parent = parent;\n        this.threadId = threadId;\n        this.hash = hash;\n        this.path = path;\n        this.line = line;\n    }\n\n    public Optional<ReviewComment> parent() {\n        return Optional.ofNullable(parent);\n    }\n\n    /**\n     * The hash for the commit for which this review comment was created. Can be empty if the commit\n     * no longer exists.\n     */\n    public Optional<Hash> hash() {\n        return Optional.ofNullable(hash);\n    }\n\n    public String path() {\n        return path;\n    }\n\n    public int line() {\n        return line;\n    }\n\n    public String threadId() {\n        return threadId;\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        if (!super.equals(o)) {\n            return false;\n        }\n        ReviewComment that = (ReviewComment) o;\n        return line == that.line &&\n                Objects.equals(parent, that.parent) &&\n                threadId.equals(that.threadId) &&\n                hash.equals(that.hash) &&\n                path.equals(that.path);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(super.hashCode(), parent, threadId, hash, path, line);\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/WebHook.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.util.List;\n\npublic class WebHook {\n\n    private final List<PullRequest> updatedPullRequests;\n\n    public WebHook(List<PullRequest> updatedPullRequests) {\n        this.updatedPullRequests = updatedPullRequests;\n    }\n\n    public List<PullRequest> updatedPullRequests() {\n        return updatedPullRequests;\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/WorkflowStatus.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\npublic enum WorkflowStatus {\n    NOT_CONFIGURED,\n    ENABLED,\n    DISABLED,\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/bitbucket/BitbucketForgeFactory.java",
    "content": "/*\n * Copyright (c) 2023, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.bitbucket;\n\nimport java.net.URI;\nimport java.util.Set;\nimport java.util.stream.Collectors;\nimport org.openjdk.skara.forge.Forge;\nimport org.openjdk.skara.forge.ForgeFactory;\nimport org.openjdk.skara.forge.internal.ForgeUtils;\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.json.JSONObject;\nimport org.openjdk.skara.json.JSONValue;\n\npublic class BitbucketForgeFactory implements ForgeFactory {\n\n    @Override\n    public String name() {\n        return \"bitbucket\";\n    }\n\n    @Override\n    public Set<String> knownHosts() {\n        return Set.of();\n    }\n\n    @Override\n    public Forge create(URI uri, Credential credential, JSONObject configuration) {\n        var name = \"Bitbucket\";\n        String prTemplate = null;\n        if (configuration != null) {\n            if (configuration.contains(\"name\")) {\n                name = configuration.get(\"name\").asString();\n            }\n            if (configuration.contains(\"prTemplate\")) {\n                prTemplate = configuration.get(\"prTemplate\")\n                    .asArray()\n                    .stream()\n                    .map(JSONValue::asString)\n                    .collect(Collectors.joining(\"\\n\"));\n            }\n        }\n        var useSsh = false;\n        if (configuration != null && configuration.contains(\"sshkey\")) {\n            if (credential == null) {\n                throw new RuntimeException(\"Cannot use SSH without credentials\");\n            }\n            ForgeUtils.configureSshKey(credential.username(), uri.getHost(), configuration.get(\"sshkey\").asString());\n            useSsh = true;\n        }\n        int sshport = BitbucketHost.DEFAULT_SSH_PORT;\n        if (configuration != null && configuration.contains(\"sshport\")) {\n            sshport = configuration.get(\"sshport\").asInt();\n        }\n        return new BitbucketHost(name, uri, useSsh, sshport, credential, prTemplate);\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/bitbucket/BitbucketHost.java",
    "content": "/*\n * Copyright (c) 2023, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.bitbucket;\n\nimport java.net.URI;\nimport java.util.List;\nimport java.util.Optional;\nimport org.openjdk.skara.forge.Forge;\nimport org.openjdk.skara.forge.HostedCommit;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.forge.MemberState;\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.vcs.Hash;\n\npublic class BitbucketHost implements Forge {\n    public static final int DEFAULT_SSH_PORT = 22;\n    private final String name;\n    private final URI uri;\n    private final boolean useSsh;\n    private final int sshPort;\n    private final Credential credential;\n    private final String pullRequestTemplate;\n\n    public BitbucketHost(String name, URI uri, boolean useSsh, int sshPort, Credential credential, String pullRequestTemplate) {\n        this.name = name;\n        this.uri = uri;\n        this.useSsh = useSsh;\n        this.sshPort = sshPort;\n        this.credential = credential;\n        this.pullRequestTemplate = pullRequestTemplate;\n    }\n\n    @Override\n    public String name() {\n        return name;\n    }\n\n    @Override\n    public Optional<String> defaultPullRequestTemplate() {\n        return Optional.ofNullable(pullRequestTemplate);\n    }\n\n    public URI getUri() {\n        return uri;\n    }\n\n    public String sshHostString() {\n        if (credential == null) {\n            throw new IllegalStateException(\"Cannot use ssh without user name\");\n        }\n        return credential.username() + \".\" + uri.getHost() + ((sshPort != DEFAULT_SSH_PORT) ? \":\" + sshPort : \"\");\n    }\n\n    boolean useSsh() {\n        return useSsh;\n    }\n\n    Optional<Credential> getCredential() {\n        return Optional.ofNullable(credential);\n    }\n\n    @Override\n    public Optional<HostedRepository> repository(String name) {\n        return Optional.of(new BitbucketRepository(this, name));\n    }\n\n    @Override\n    public Optional<String> search(Hash hash, boolean includeDiffs) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public boolean isValid() {\n        return true;\n    }\n\n    @Override\n    public Optional<HostUser> user(String username) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public Optional<HostUser> userById(String username) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public HostUser currentUser() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public boolean isMemberOf(String groupId, HostUser user) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public String hostname() {\n        return uri.getHost();\n    }\n\n    @Override\n    public List<HostUser> groupMembers(String group) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void addGroupMember(String group, HostUser user) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public MemberState groupMemberState(String group, HostUser user) {\n        throw new UnsupportedOperationException();\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/bitbucket/BitbucketRepository.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.bitbucket;\n\nimport java.net.URI;\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\nimport org.openjdk.skara.forge.Check;\nimport org.openjdk.skara.forge.Collaborator;\nimport org.openjdk.skara.forge.CommitComment;\nimport org.openjdk.skara.forge.Forge;\nimport org.openjdk.skara.forge.HostedBranch;\nimport org.openjdk.skara.forge.HostedCommit;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.forge.WebHook;\nimport org.openjdk.skara.forge.WorkflowStatus;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Label;\nimport org.openjdk.skara.json.JSONValue;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.vcs.Branch;\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.ReadOnlyRepository;\nimport org.openjdk.skara.vcs.Tag;\nimport org.openjdk.skara.vcs.VCS;\n\npublic class BitbucketRepository implements HostedRepository {\n    private final BitbucketHost host;\n    private final String name;\n\n    public BitbucketRepository(BitbucketHost host, String name) {\n        this.host = host;\n        this.name = name;\n    }\n\n    @Override\n    public Forge forge() {\n        return host;\n    }\n\n    @Override\n    public PullRequest createPullRequest(HostedRepository target, String targetRef, String sourceRef, String title, List<String> body, boolean draft) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public PullRequest pullRequest(String id) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public List<PullRequest> pullRequests() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public List<PullRequest> openPullRequests() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public List<PullRequest> pullRequestsAfter(ZonedDateTime updatedAfter) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public List<PullRequest> openPullRequestsAfter(ZonedDateTime updatedAfter) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public List<PullRequest> findPullRequestsWithComment(String author, String body) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public Optional<PullRequest> parsePullRequestUrl(String url) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public String name() {\n        return name;\n    }\n\n    @Override\n    public String group() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public Optional<HostedRepository> parent() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public URI authenticatedUrl() {\n        if (host.useSsh()) {\n            return URI.create(\"ssh://git@\" + host.sshHostString() + \"/\" + name + \".git\");\n        } else {\n            var builder = URIBuilder\n                    .base(host.getUri())\n                    .setPath(\"/\" + name + \".git\");\n            host.getCredential().ifPresent(cred -> builder.setAuthentication(cred.username() + \":\" + cred.password()));\n            return builder.build();\n        }\n    }\n\n    @Override\n    public URI webUrl() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public URI nonTransformedWebUrl() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public URI webUrl(Hash hash) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public URI webUrl(Branch branch) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public URI webUrl(Tag tag) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public URI webUrl(String baseRef, String headRef) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public URI diffUrl(String prId) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public VCS repositoryType() {\n        return VCS.GIT;\n    }\n\n    @Override\n    public URI url() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public Optional<String> fileContents(String filename, String ref) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void writeFileContents(String filename, String content, Branch branch, String message, String authorName, String authorEmail, boolean createNewFile) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public String namespace() {\n        return URIBuilder.base(host.getUri()).build().getHost();\n    }\n\n    @Override\n    public Optional<WebHook> parseWebHook(JSONValue body) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public HostedRepository fork() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public long id() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public Optional<Hash> branchHash(String ref) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public List<HostedBranch> branches() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public String defaultBranchName() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void protectBranchPattern(String pattern) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void unprotectBranchPattern(String pattern) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void deleteBranch(String ref) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public List<CommitComment> commitComments(Hash hash) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public List<CommitComment> recentCommitComments(ReadOnlyRepository localRepo, Set<Integer> excludeAuthors, List<Branch> Branches, ZonedDateTime updatedAfter) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public CommitComment addCommitComment(Hash hash, String body) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void updateCommitComment(String id, String body) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public Optional<HostedCommit> commit(Hash hash, boolean includeDiffs) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public List<Check> allChecks(Hash hash) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public WorkflowStatus workflowStatus() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public URI createPullRequestUrl(HostedRepository target, String targetRef, String sourceRef) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public List<Collaborator> collaborators() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void addCollaborator(HostUser user, boolean canPush) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void removeCollaborator(HostUser user) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public boolean canPush(HostUser user) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void restrictPushAccess(Branch branch, HostUser users) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public List<Label> labels() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void addLabel(Label label) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void updateLabel(Label label) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void deleteLabel(Label label) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public int deleteDeployKeys(Duration age) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public boolean canCreatePullRequest(HostUser user) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public List<PullRequest> openPullRequestsWithTargetRef(String targetRef) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public List<String> deployKeyTitles(Duration age) {\n        throw new UnsupportedOperationException();\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/github/GitHubApplication.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.github;\n\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.network.URIBuilder;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.net.http.*;\nimport java.nio.charset.StandardCharsets;\nimport java.security.*;\nimport java.security.spec.*;\nimport java.time.*;\nimport java.util.Base64;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\n\nclass GitHubApplicationError extends RuntimeException {\n    GitHubApplicationError(String msg) {\n        super(msg);\n    }\n}\n\nclass Token {\n\n    static class GeneratorError extends Exception {\n        public GeneratorError(String message) { super(message); }\n    }\n\n    public interface TokenGenerator {\n        String generate() throws GeneratorError;\n    }\n\n    private final TokenGenerator generator;\n    private final Duration expiration;\n    private String cached;\n    private Instant generatedAt;\n\n    Token(TokenGenerator generator, Duration expiration) {\n        this.generator = generator;\n        this.expiration = expiration;\n    }\n\n    public void expire() {\n        generatedAt = null;\n    }\n\n    @Override\n    public String toString() {\n\n        if (generatedAt != null) {\n            if (generatedAt.plus(expiration).isAfter(Instant.now())) {\n                return cached;\n            }\n        }\n\n        try {\n            cached = generator.generate();\n            generatedAt = Instant.now();\n            return cached;\n        } catch (GeneratorError generatorError) {\n            // FIXME? The operation could be retried here\n            throw new GitHubApplicationError(\"Failed to generate authentication token (\" + generatorError.getMessage() + \")\");\n        }\n    }\n}\n\npublic class GitHubApplication {\n    private final String issue;\n    private final String id;\n\n    private final URI apiBase;\n    private final PrivateKey key;\n    private final Token jwt;\n    private final Token installationToken;\n\n    private final Logger log;\n    private static final HttpClient client = HttpClient.newBuilder()\n                                                       .connectTimeout(Duration.ofSeconds(10))\n                                                       .build();\n\n    static class GitHubConfigurationError extends RuntimeException {\n        public GitHubConfigurationError(String message) {\n            super(message);\n        }\n    }\n\n    public GitHubApplication(String key, String issue, String id) {\n\n        log = Logger.getLogger(\"org.openjdk.host.github\");\n\n        apiBase = URIBuilder.base(\"https://api.github.com/\").build();\n        this.issue = issue;\n        this.id = id;\n\n        this.key = loadPkcs8PemFromString(key);\n        jwt = new Token(this::generateJsonWebToken, Duration.ofMinutes(5));\n        installationToken = new Token(this::generateInstallationToken, Duration.ofMinutes(30));\n    }\n\n    private PrivateKey loadPkcs8PemFromString(String pem) {\n        try {\n            var pemPattern = Pattern.compile(\"^-*BEGIN PRIVATE KEY-*$(.*)^-*END PRIVATE KEY-*\",\n                    Pattern.DOTALL | Pattern.MULTILINE);\n            var keyString = pemPattern.matcher(pem).replaceFirst(\"$1\");\n            //keyString = keyString.replace(\"\\n\", \"\");\n            var rawKey = Base64.getMimeDecoder().decode(keyString);\n            var factory = KeyFactory.getInstance(\"RSA\");\n            return factory.generatePrivate(new PKCS8EncodedKeySpec(rawKey));\n        } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {\n            throw new GitHubConfigurationError(\"Unable to load private key (\" + e + \")\");\n        }\n    }\n\n    private String generateJsonWebToken() {\n        var issuedAt = ZonedDateTime.now(ZoneOffset.UTC);\n        var expires = issuedAt.plus(Duration.ofMinutes(8));\n\n        var header = Base64.getUrlEncoder().encode(JSON.object()\n                                                       .put(\"alg\", \"RS256\")\n                                                       .put(\"typ\", \"JWT\")\n                                                       .toString().getBytes(StandardCharsets.UTF_8));\n        var claims = Base64.getUrlEncoder().encode(JSON.object()\n                .put(\"iss\", issue)\n                .put(\"iat\", (int)issuedAt.toEpochSecond())\n                .put(\"exp\", (int)expires.toEpochSecond())\n                .toString().getBytes(StandardCharsets.UTF_8));\n        var separator = \".\".getBytes(StandardCharsets.UTF_8);\n\n        try {\n            var signer = Signature.getInstance(\"SHA256withRSA\");\n            signer.initSign(key);\n            var payload = new ByteArrayOutputStream();\n            payload.write(header);\n            payload.write(separator);\n            payload.write(claims);\n            signer.update(payload.toByteArray());\n            var signature = Base64.getUrlEncoder().encode(signer.sign());\n\n            var token = new ByteArrayOutputStream();\n            token.write(header);\n            token.write(separator);\n            token.write(claims);\n            token.write(separator);\n            token.write(signature);\n\n            return token.toString(StandardCharsets.US_ASCII);\n        } catch (NoSuchAlgorithmException | SignatureException e) {\n            throw new RuntimeException(e);\n        } catch (InvalidKeyException e) {\n            throw new GitHubConfigurationError(\"Invalid private key\");\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    private String generateInstallationToken() throws Token.GeneratorError {\n        var tokens = URIBuilder.base(apiBase).setPath(\"/app/installations/\" + id + \"/access_tokens\").build();\n\n        try {\n            var response = client.send(\n                    HttpRequest.newBuilder()\n                               .uri(tokens)\n                               .timeout(Duration.ofSeconds(30))\n                               .header(\"Authorization\", \"Bearer \" + jwt)\n                               .header(\"Accept\", \"application/vnd.github.machine-man-preview+json\")\n                               .POST(HttpRequest.BodyPublishers.noBody())\n                               .build(),\n                    HttpResponse.BodyHandlers.ofString()\n            );\n\n            var data = JSON.parse(response.body());\n            if (!data.contains(\"token\")) {\n                throw new Token.GeneratorError(\"Unknown data returned: \" + data);\n            }\n            return data.get(\"token\").asString();\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        } catch (InterruptedException e) {\n            throw new Token.GeneratorError(e.toString());\n        }\n    }\n\n    public String getInstallationToken() {\n        return installationToken.toString();\n    }\n\n    JSONObject getAppDetails() {\n        var details = URIBuilder.base(apiBase).setPath(\"/app\").build();\n\n        try {\n            var response = client.send(\n                    HttpRequest.newBuilder()\n                               .uri(details)\n                               .timeout(Duration.ofSeconds(30))\n                               .header(\"Authorization\", \"Bearer \" + jwt)\n                               .header(\"Accept\", \"application/vnd.github.machine-man-preview+json\")\n                               .GET()\n                               .build(),\n                    HttpResponse.BodyHandlers.ofString()\n            );\n\n            var data = JSON.parse(response.body());\n            return data.asObject();\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        } catch (InterruptedException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    String authId() {\n        return id;\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/github/GitHubForgeFactory.java",
    "content": "/*\n * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.github;\n\nimport java.util.List;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.json.JSONObject;\nimport org.openjdk.skara.json.JSONValue;\n\nimport java.net.URI;\nimport java.util.Set;\nimport java.util.HashSet;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class GitHubForgeFactory implements ForgeFactory {\n    @Override\n    public String name() {\n        return \"github\";\n    }\n\n    @Override\n    public Set<String> knownHosts() {\n        return Set.of(\"github.com\");\n    }\n\n    @Override\n    public Forge create(URI uri, Credential credential, JSONObject configuration) {\n        Pattern webUriPattern = null;\n        String webUriReplacement = null;\n        List<String> altwebUriReplacements = List.of();\n        var offline = false;\n        Set<String> orgs = new HashSet<>();\n        String prTemplate = null;\n\n        if (configuration != null) {\n            if (configuration.contains(\"weburl\")) {\n                var weburl = configuration.get(\"weburl\");\n                webUriPattern = Pattern.compile(weburl.get(\"pattern\").asString());\n                webUriReplacement = weburl.get(\"replacement\").asString();\n                if (weburl.contains(\"altreplacements\")) {\n                    altwebUriReplacements = weburl.get(\"altreplacements\").asArray().stream()\n                            .map(JSONValue::asString)\n                            .toList();\n                }\n            }\n\n            if (configuration.contains(\"offline\")) {\n                offline = configuration.get(\"offline\").asBoolean();\n            }\n\n            if (configuration.contains(\"orgs\")) {\n                orgs = configuration.get(\"orgs\")\n                    .stream()\n                    .map(JSONValue::asString)\n                    .collect(Collectors.toSet());\n            }\n\n            if (configuration.contains(\"prTemplate\")) {\n                prTemplate = configuration.get(\"prTemplate\")\n                    .asArray()\n                    .stream()\n                    .map(JSONValue::asString)\n                    .collect(Collectors.joining(\"\\n\"));\n            }\n        }\n\n        if (credential != null) {\n            if (credential.username().contains(\";\")) {\n                var separator = credential.username().indexOf(\";\");\n                var id = credential.username().substring(0, separator);\n                var installation = credential.username().substring(separator + 1);\n                var app = new GitHubApplication(credential.password(), id, installation);\n                return new GitHubHost(uri, app, webUriPattern, webUriReplacement, altwebUriReplacements, orgs, prTemplate);\n            } else {\n                return new GitHubHost(uri, credential, webUriPattern, webUriReplacement, altwebUriReplacements, orgs, prTemplate);\n            }\n        } else {\n            return new GitHubHost(uri, webUriPattern, webUriReplacement, altwebUriReplacements, orgs, prTemplate, offline);\n        }\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/github/GitHubHost.java",
    "content": "/*\n * Copyright (c) 2018, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.github;\n\nimport java.util.stream.Stream;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.network.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.IOException;\nimport java.net.*;\nimport java.nio.charset.StandardCharsets;\nimport java.time.*;\nimport java.util.*;\nimport java.util.stream.Collectors;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\n\npublic class GitHubHost implements Forge {\n    private final URI uri;\n    private final Pattern webUriPattern;\n    private final String webUriReplacement;\n    private final List<String> altWebUriReplacements;\n    private final GitHubApplication application;\n    private final Credential pat;\n    private final RestRequest request;\n    private final RestRequest graphQL;\n    private final Duration searchInterval;\n    private HostUser currentUser;\n    private volatile Instant lastSearch = Instant.now();\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.forge.github\");\n    private final Set<String> orgs;\n    private final String pullRequestTemplate;\n    // If this Forge is created as offline, it will avoid making remote calls\n    // when not needed. This is currently limited to only prevent validation\n    // when creating a repository object.\n    private final boolean offline;\n\n    public GitHubHost(URI uri, GitHubApplication application, Pattern webUriPattern, String webUriReplacement,\n            List<String> altWebUriReplacements, Set<String> orgs, String pullRequestTemplate) {\n        this.uri = uri;\n        this.webUriPattern = webUriPattern;\n        this.webUriReplacement = webUriReplacement;\n        this.altWebUriReplacements = altWebUriReplacements;\n        this.application = application;\n        this.pat = null;\n        this.orgs = orgs;\n        this.pullRequestTemplate = pullRequestTemplate;\n        offline = false;\n        searchInterval = Duration.ofSeconds(3);\n\n        var baseApi = URIBuilder.base(uri)\n                .appendSubDomain(\"api\")\n                .setPath(\"/\")\n                .build();\n\n        request = new RestRequest(baseApi, application.authId(), (r) -> Arrays.asList(\n                \"Authorization\", \"token \" + getInstallationToken().orElseThrow(),\n                \"Accept\", \"application/vnd.github.machine-man-preview+json\",\n                \"Accept\", \"application/vnd.github.antiope-preview+json\",\n                \"Accept\", \"application/vnd.github.cloak-preview+json\"\n        ));\n\n        var graphQLAPI = URIBuilder.base(uri)\n                .appendSubDomain(\"api\")\n                .setPath(\"/graphql\")\n                .build();\n        graphQL = new RestRequest(graphQLAPI, application.authId(), (r) -> Arrays.asList(\n                \"Authorization\", \"bearer \" + getInstallationToken().orElseThrow(),\n                \"Accept\", \"application/vnd.github.machine-man-preview+json\",\n                \"Accept\", \"application/vnd.github.antiope-preview+json\",\n                \"Accept\", \"application/vnd.github.shadow-cat-preview+json\",\n                \"Accept\", \"application/vnd.github.comfort-fade-preview+json\"\n        ));\n    }\n\n    RestRequest graphQL() {\n        if (graphQL == null) {\n            throw new IllegalStateException(\"Cannot use GraphQL API without authorization\");\n        }\n        return graphQL;\n    }\n\n    public GitHubHost(URI uri, Credential pat, Pattern webUriPattern, String webUriReplacement,\n            List<String> altWebUriReplacements, Set<String> orgs, String pullRequestTemplate) {\n        this.uri = uri;\n        this.webUriPattern = webUriPattern;\n        this.webUriReplacement = webUriReplacement;\n        this.altWebUriReplacements = altWebUriReplacements;\n        this.pat = pat;\n        this.application = null;\n        this.orgs = orgs;\n        this.pullRequestTemplate = pullRequestTemplate;\n        offline = false;\n        searchInterval = Duration.ofSeconds(3);\n\n        var baseApi = URIBuilder.base(uri)\n                                .appendSubDomain(\"api\")\n                                .setPath(\"/\")\n                                .build();\n\n        request = new RestRequest(baseApi, pat.username(), (r) -> Arrays.asList(\n                \"Authorization\", \"token \" + getInstallationToken().orElseThrow(),\n                \"Accept\", \"application/vnd.github.machine-man-preview+json\",\n                \"Accept\", \"application/vnd.github.antiope-preview+json\",\n                \"Accept\", \"application/vnd.github.cloak-preview+json\"\n        ));\n\n        var graphQLAPI = URIBuilder.base(uri)\n                .appendSubDomain(\"api\")\n                .setPath(\"/graphql\")\n                .build();\n        graphQL = new RestRequest(graphQLAPI, pat.username(), (r) -> Arrays.asList(\n                \"Authorization\", \"bearer \" + getInstallationToken().orElseThrow(),\n                \"Accept\", \"application/vnd.github.machine-man-preview+json\",\n                \"Accept\", \"application/vnd.github.antiope-preview+json\",\n                \"Accept\", \"application/vnd.github.shadow-cat-preview+json\",\n                \"Accept\", \"application/vnd.github.comfort-fade-preview+json\"\n        ));\n    }\n\n    GitHubHost(URI uri, Pattern webUriPattern, String webUriReplacement,\n            List<String> altWebUriReplacements, Set<String> orgs, String pullRequestTemplate,\n            boolean offline) {\n        this.uri = uri;\n        this.webUriPattern = webUriPattern;\n        this.webUriReplacement = webUriReplacement;\n        this.altWebUriReplacements = altWebUriReplacements;\n        this.pat = null;\n        this.application = null;\n        this.orgs = orgs;\n        this.pullRequestTemplate = pullRequestTemplate;\n        this.offline = offline;\n        searchInterval = Duration.ofSeconds(10);\n\n        var baseApi = URIBuilder.base(uri)\n                                .appendSubDomain(\"api\")\n                                .setPath(\"/\")\n                                .build();\n\n        request = new RestRequest(baseApi);\n        graphQL = null;\n    }\n\n    public URI getURI() {\n        return uri;\n    }\n\n    @Override\n    public String name() {\n        return \"GitHub\";\n    }\n\n    @Override\n    public String hostname() {\n        return uri.getHost();\n    }\n\n    @Override\n    public Optional<String> defaultPullRequestTemplate() {\n        return Optional.ofNullable(pullRequestTemplate);\n    }\n\n    boolean isOffline() {\n        return offline;\n    }\n\n    Set<String> organizations() {\n        return Set.copyOf(orgs);\n    }\n\n    URI getWebURI(String endpoint) {\n        return getWebURI(endpoint, true);\n    }\n\n    URI getWebURI(String endpoint, boolean transform) {\n        var baseWebUri = URIBuilder.base(uri)\n                                   .setPath(endpoint)\n                                   .build();\n\n        if (webUriPattern == null || !transform) {\n            return baseWebUri;\n        }\n\n        var matcher = webUriPattern.matcher(baseWebUri.toString());\n        if (!matcher.matches()) {\n            return baseWebUri;\n\n        }\n        return URIBuilder.base(matcher.replaceAll(webUriReplacement)).build();\n    }\n\n    /**\n     * Gets a list of all the alternative URIs for this host for a given endpoint\n     * @param endpoint Endpoint to resolve\n     * @return List of URIs\n     */\n    List<URI> getAllWebURIs(String endpoint) {\n        var mainURI = getWebURI(endpoint);\n\n        if (altWebUriReplacements.isEmpty()) {\n            return List.of(mainURI);\n        }\n        var baseWebUri = URIBuilder.base(uri)\n                .setPath(endpoint)\n                .build();\n\n        var matcher = webUriPattern.matcher(baseWebUri.toString());\n        if (!matcher.matches()) {\n            return List.of(mainURI);\n        }\n\n        return Stream.concat(Stream.of(mainURI),\n                        altWebUriReplacements.stream()\n                                .map(r -> URIBuilder.base(matcher.replaceAll(r)).build()))\n                .toList();\n    }\n\n    Optional<String> getInstallationToken() {\n        if (application != null) {\n            return Optional.of(application.getInstallationToken());\n        }\n\n        if (pat != null) {\n            return Optional.of(pat.password());\n        }\n\n        return Optional.empty();\n    }\n\n    Optional<String> authId() {\n        if (application != null) {\n            return Optional.of(application.authId());\n        }\n\n        if (pat != null) {\n            return Optional.of(pat.username());\n        }\n\n        return Optional.empty();\n    }\n\n    // Most GitHub API's return user information in this format\n    HostUser parseUserField(JSONValue json) {\n        return parseUserObject(json.get(\"user\"));\n    }\n\n    HostUser parseUserObject(JSONValue json) {\n        return hostUser(json.get(\"id\").asInt(), json.get(\"login\").asString());\n    }\n\n    HostUser hostUser(int id, String username) {\n        return HostUser.builder()\n                       .id(id)\n                       .username(username)\n                       .supplier(() -> user(username).orElseGet(() -> HostUser.builder()\n                               .id(id)\n                               .username(username)\n                               .build()))\n                       .build();\n    }\n\n    @Override\n    public boolean isValid() {\n        try {\n            var endpoints = request.get(\"\")\n                                   .executeUnparsed();\n            var parsed = JSON.parse(endpoints);\n            if (parsed != null && parsed.contains(\"current_user_url\")) {\n                return true;\n            } else {\n                log.fine(\"Error during GitHub host validation: unexpected endpoint list: \" + endpoints);\n                return false;\n            }\n        } catch (IOException | UncheckedRestException e) {\n            log.fine(\"Error during GitHub host validation: \" + e);\n            return false;\n        }\n    }\n\n    Optional<JSONObject> getProjectInfo(String name) {\n        var project = request.get(\"repos/\" + name)\n                .onError(r -> r.statusCode() == 404 ? Optional.of(JSON.object().put(\"NOT_FOUND\", true)) : Optional.empty())\n                .execute();\n        if (project.contains(\"NOT_FOUND\")) {\n            return Optional.empty();\n        }\n        return Optional.of(project.asObject());\n    }\n\n    JSONObject runSearch(String category, String query) {\n        // Searches on GitHub uses a special rate limit, so make sure to wait between consecutive searches\n        while (true) {\n            synchronized (this) {\n                if (lastSearch.isBefore(Instant.now().minus(searchInterval))) {\n                    lastSearch = Instant.now();\n                    break;\n                }\n            }\n            log.fine(\"Searching too fast - waiting\");\n            try {\n                Thread.sleep(Duration.ofMillis(500));\n            } catch (InterruptedException ignored) {\n            }\n        }\n        var result = request.get(\"search/\" + category)\n                            .param(\"q\", query)\n                            .execute();\n        return result.asObject();\n    }\n\n    @Override\n    public Optional<HostedRepository> repository(String name) {\n        if (offline) {\n            return Optional.of(new GitHubRepository(this, name));\n        } else {\n            return getProjectInfo(name)\n                    .map(jsonObject -> new GitHubRepository(this, name, jsonObject));\n        }\n    }\n\n    @Override\n    public Optional<HostUser> user(String username) {\n        var details = request.get(\"users/\" + URLEncoder.encode(username, StandardCharsets.UTF_8))\n                             .onError(r -> r.statusCode() == 404 ? Optional.of(JSON.of()) : Optional.empty())\n                             .execute();\n        if (details.isNull()) {\n            return Optional.empty();\n        }\n\n        return Optional.of(toHostUser(details.asObject()));\n    }\n\n    @Override\n    public Optional<HostUser> userById(String id) {\n        var details = request.get(\"user/\" + id)\n                .onError(r -> r.statusCode() == 404 ? Optional.of(JSON.of()) : Optional.empty())\n                .execute();\n        if (details.isNull()) {\n            return Optional.empty();\n        }\n\n        return Optional.of(toHostUser(details.asObject()));\n    }\n\n    /**\n     * Gets all members of a GitHub organization.\n     */\n    @Override\n    public List<HostUser> groupMembers(String group) {\n        var result = request.get(\"orgs/\" + group + \"/members\").execute();\n        return result.stream().map(o -> toHostUser(o.asObject())).toList();\n    }\n\n    /**\n     * Gets the membership state of a user in a GitHub organization. Since\n     * member invitations need to be accepted by the user, it can be either\n     * PENDING or ACTIVE. If the user isn't a member, the state is MISSING.\n     */\n    @Override\n    public MemberState groupMemberState(String group, HostUser user) {\n        var result = request.get(\"orgs/\" + group + \"/memberships/\" + user.username())\n                .onError(r -> r.statusCode() == 404 ? Optional.of(JSON.object().put(\"state\", \"missing\")) : Optional.empty())\n                .execute();\n        var state = result.get(\"state\").asString();\n        return switch (state) {\n            case \"active\" -> MemberState.ACTIVE;\n            case \"pending\" -> MemberState.PENDING;\n            case \"missing\" -> MemberState.MISSING;\n            default -> throw new IllegalStateException(\"Unknown state: \" + state);\n        };\n    }\n\n    /**\n     * Adds a user to a GitHub organization. This will put the user as PENDING\n     * and an invitation is sent to the user. When accepted, the user becomes\n     * ACTIVE.\n     */\n    @Override\n    public void addGroupMember(String group, HostUser user) {\n        request.put(\"orgs/\" + group + \"/memberships/\" + user.username())\n                .body(\"role\", \"member\")\n                .execute();\n    }\n\n    /**\n     * Generate a HostUser object from the json snippet. Depending on the source,\n     * not all fields may be present.\n     */\n    static HostUser toHostUser(JSONObject details) {\n        // Always present\n        var login = details.get(\"login\").asString();\n        var id = details.get(\"id\").asInt();\n        // Sometimes present\n        var name = details.contains(\"name\") ? details.get(\"name\").asString() : login;\n        var email = details.contains(\"email\") ? details.get(\"email\").asString() : null;\n        return HostUser.builder()\n                       .id(id)\n                       .username(login)\n                       .fullName(name)\n                       .email(email)\n                       .build();\n    }\n\n    @Override\n    public HostUser currentUser() {\n        if (currentUser == null) {\n            if (application != null) {\n                var appDetails = application.getAppDetails();\n                var appName = appDetails.get(\"name\").asString() + \"[bot]\";\n                currentUser = user(appName).get();\n            } else if (pat != null) {\n                // Cannot always trust username in PAT, e.g. Git Credential Manager\n                // on Windows always return \"PersonalAccessToken\" as username.\n                // Query GitHub for the username instead.\n                var details = request.get(\"user\").execute().asObject();\n                currentUser = toHostUser(details);\n            } else {\n                throw new IllegalStateException(\"No credentials present\");\n            }\n        }\n        return currentUser;\n    }\n\n    @Override\n    public boolean isMemberOf(String groupId, HostUser user) {\n        long gid = 0L;\n        try {\n            gid = Long.parseLong(groupId);\n        } catch (NumberFormatException e) {\n            throw new IllegalArgumentException(\"Group id is not a number: \" + groupId);\n        }\n        var username = URLEncoder.encode(user.username(), StandardCharsets.UTF_8);\n        var orgs = request.get(\"users/\" + username + \"/orgs\").execute().asArray();\n        for (var org : orgs) {\n            if (org.get(\"id\").asLong() == gid) {\n                return true;\n            }\n        }\n\n        return false;\n    }\n\n    @Override\n    public Optional<String> search(Hash hash, boolean includeDiffs) {\n        var orgsToSearch = orgs.stream().map(o -> \"org:\" + o).collect(Collectors.joining(\" \"));\n        if (!orgsToSearch.isEmpty()) {\n            orgsToSearch = \" \" + orgsToSearch;\n        }\n        // /search/commits can only find commits in default branch of each repository\n        var result = runSearch(\"commits\", \"hash:\" + hash.hex() + orgsToSearch);\n        var items = result.get(\"items\").asArray();\n        if (!items.isEmpty()) {\n            // When searching for a commit, there may be hits in multiple repositories.\n            // There is no good way of knowing for sure which repository we would rather\n            // get the commit from, but a reasonable default is to go by the shortest\n            // name as that is most likely the main repository of the project.\n            return items.stream()\n                    .map(o -> o.get(\"repository\").get(\"full_name\").asString())\n                    .min(Comparator.comparing(String::length));\n        }\n\n        // If the commit is not found using /search/commits, try to locate it in each repository\n        for (var org : orgs) {\n            var repoNames = request.get(\"orgs/\" + org + \"/repos\")\n                    .execute()\n                    .stream()\n                    .sorted(Comparator.comparing(o -> o.get(\"full_name\").asString().length()))\n                    .map(o -> o.get(\"full_name\").asString())\n                    .toList();\n\n            for (var repoName : repoNames) {\n                var githubRepo = new GitHubRepository(this, repoName);\n                if (githubRepo.commit(hash).isPresent()) {\n                    return Optional.of(repoName);\n                }\n            }\n        }\n\n        return Optional.empty();\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/github/GitHubPullRequest.java",
    "content": "/*\n * Copyright (c) 2018, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.github;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.network.*;\nimport org.openjdk.skara.vcs.Diff;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.net.URI;\nimport java.time.*;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\npublic class GitHubPullRequest implements PullRequest {\n    private final JSONValue json;\n    private final RestRequest request;\n    private final GitHubHost host;\n    private final GitHubRepository repository;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.host\");\n\n    private List<Label> labels = null;\n\n    private static final int GITHUB_PR_COMMENT_BODY_MAX_SIZE = 64_000;\n\n    GitHubPullRequest(GitHubRepository repository, JSONValue jsonValue, RestRequest request) {\n        this.host = (GitHubHost)repository.forge();\n        this.repository = repository;\n        this.request = request;\n        this.json = jsonValue;\n\n        labels = json.get(\"labels\")\n                     .stream()\n                     .map(v -> new Label(v.get(\"name\").asString(), v.get(\"description\").asString()))\n                     .sorted()\n                     .collect(Collectors.toList());\n    }\n\n    @Override\n    public HostedRepository repository() {\n        return repository;\n    }\n\n    @Override\n    public IssueProject project() {\n        return null;\n    }\n\n    @Override\n    public String id() {\n        return json.get(\"number\").toString();\n    }\n\n    @Override\n    public HostUser author() {\n        return host.parseUserField(json);\n    }\n\n    @Override\n    public List<Review> reviews() {\n        var currentTargetRef = targetRef();\n        var reviews = request.get(\"pulls/\" + json.get(\"number\").toString() + \"/reviews\")\n                .param(\"per_page\", \"100\").execute().stream()\n                .map(JSONValue::asObject)\n                .filter(obj -> !(obj.get(\"state\").asString().equals(\"COMMENTED\") && obj.get(\"body\").asString().isEmpty()))\n                .map(obj -> {\n                    var reviewer = host.parseUserField(obj);\n                    var commitId = obj.get(\"commit_id\");\n                    Hash hash = null;\n                    if (commitId != null) {\n                        hash = new Hash(commitId.asString());\n                    }\n                    Review.Verdict verdict;\n                    switch (obj.get(\"state\").asString()) {\n                        case \"APPROVED\":\n                            verdict = Review.Verdict.APPROVED;\n                            break;\n                        case \"CHANGES_REQUESTED\":\n                            verdict = Review.Verdict.DISAPPROVED;\n                            break;\n                        default:\n                            verdict = Review.Verdict.NONE;\n                            break;\n                    }\n                    var id = obj.get(\"id\").toString();\n                    var body = obj.get(\"body\").asString();\n                    var createdAt = ZonedDateTime.parse(obj.get(\"submitted_at\").asString());\n                    return new Review(createdAt, reviewer, verdict, hash, id, body, currentTargetRef);\n                })\n                .collect(Collectors.toList());\n\n        var targetRefChanges = targetRefChanges();\n        return PullRequest.calculateReviewTargetRefs(reviews, targetRefChanges);\n    }\n\n    @Override\n    public List<ReferenceChange> targetRefChanges() {\n        // If the base ref has changed after a review, we treat those as invalid - unless it was a PreIntegration ref\n        var parts = repository.name().split(\"/\");\n        var owner = parts[0];\n        var name = parts[1];\n        var number = id();\n\n        var query = \"{\\n\" +\n                \"  repository(owner: \\\"\" + owner + \"\\\", name: \\\"\" + name + \"\\\") {\\n\" +\n                \"    pullRequest(number: \" + number + \") {\\n\" +\n                \"      timelineItems(itemTypes: BASE_REF_CHANGED_EVENT, last: 10) {\\n\" +\n                \"        nodes {\\n\" +\n                \"          __typename\\n\" +\n                \"          ... on BaseRefChangedEvent {\\n\" +\n                \"            currentRefName,\\n\" +\n                \"            previousRefName,\\n\" +\n                \"            createdAt\\n\" +\n                \"          }\\n\" +\n                \"        }\\n\" +\n                \"      }\\n\" +\n                \"    }\\n\" +\n                \"  }\\n\" +\n                \"}\";\n        var data = host.graphQL()\n                .post()\n                .body(JSON.object().put(\"query\", query))\n                // This is a single point graphql query so shouldn't need to be limited to once a second\n                .skipLimiter(true)\n                .execute()\n                .get(\"data\");\n        return data.get(\"repository\").get(\"pullRequest\").get(\"timelineItems\").get(\"nodes\").stream()\n                .map(JSONValue::asObject)\n                .map(obj -> new ReferenceChange(obj.get(\"previousRefName\").asString(), obj.get(\"currentRefName\").asString(),\n                        ZonedDateTime.parse(obj.get(\"createdAt\").asString())))\n                .toList();\n    }\n\n    @Override\n    public void addReview(Review.Verdict verdict, String body) {\n        var query = JSON.object();\n        switch (verdict) {\n            case APPROVED:\n                query.put(\"event\", \"APPROVE\");\n                break;\n            case DISAPPROVED:\n                query.put(\"event\", \"REQUEST_CHANGES\");\n                break;\n            case NONE:\n                query.put(\"event\", \"COMMENT\");\n                break;\n        }\n        if (body != null && !body.isEmpty()) {\n            query.put(\"body\", body);\n        }\n        query.put(\"commit_id\", headHash().hex());\n        query.put(\"comments\", JSON.array());\n        request.post(\"pulls/\" + json.get(\"number\").toString() + \"/reviews\")\n               .body(query)\n               .execute();\n    }\n\n    @Override\n    public void updateReview(String id, String body) {\n        request.put(\"pulls/\" + json.get(\"number\").toString() + \"/reviews/\" + id)\n               .body(\"body\", body)\n               .execute();\n    }\n\n    private ReviewComment parseReviewComment(ReviewComment parent, JSONObject reviewJson, boolean includeLocationData) {\n        var author = host.parseUserField(reviewJson);\n        var threadId = parent == null ? reviewJson.get(\"id\").toString() : parent.threadId();\n\n        int line = reviewJson.get(\"original_line\").asInt();\n        var originalCommitId = reviewJson.get(\"original_commit_id\");\n        Hash hash = null;\n        if (originalCommitId != null) {\n            hash = new Hash(originalCommitId.asString());\n        }\n        var path = reviewJson.get(\"path\").asString();\n\n        if (includeLocationData && reviewJson.get(\"side\").asString().equals(\"LEFT\")) {\n            var commitInfo = request.get(\"commits/\" + hash).execute();\n\n            // If line is present, it indicates the line in the merge-base commit\n            if (!reviewJson.get(\"line\").isNull()) {\n                hash = new Hash(json.get(\"base\").get(\"sha\").asString());\n                line = reviewJson.get(\"line\").asInt();\n            } else {\n                hash = new Hash(commitInfo.get(\"parents\").asArray().get(0).get(\"sha\").asString());\n            }\n\n            // It's possible that the file in question was renamed / deleted in an earlier commit, would\n            // need to parse all the commits in the PR to be sure. But this should cover most cases.\n            for (var file : commitInfo.get(\"files\").asArray()) {\n                if (file.get(\"filename\").asString().equals(path)) {\n                    if (file.get(\"status\").asString().equals(\"renamed\")) {\n                        path = file.get(\"previous_filename\").asString();\n                    }\n                    break;\n                }\n            }\n        }\n\n        var comment = new ReviewComment(parent,\n                                        threadId,\n                                        hash,\n                                        path,\n                                        line,\n                                        reviewJson.get(\"id\").toString(),\n                                        reviewJson.get(\"body\").asString(),\n                                        author,\n                                        ZonedDateTime.parse(reviewJson.get(\"created_at\").asString()),\n                                        ZonedDateTime.parse(reviewJson.get(\"updated_at\").asString()));\n        return comment;\n    }\n\n    @Override\n    public ReviewComment addReviewComment(Hash base, Hash hash, String path, int line, String body) {\n        var query = JSON.object()\n                        .put(\"body\", body)\n                        .put(\"commit_id\", hash.hex())\n                        .put(\"path\", path)\n                        .put(\"side\", \"RIGHT\")\n                        .put(\"line\", line);\n        var response = request.post(\"pulls/\" + json.get(\"number\").toString() + \"/comments\")\n                              .body(query)\n                              .execute();\n        return parseReviewComment(null, response.asObject(), true);\n    }\n\n    @Override\n    public ReviewComment addReviewCommentReply(ReviewComment parent, String body) {\n        var query = JSON.object()\n                        .put(\"body\", body)\n                        .put(\"in_reply_to\", Integer.parseInt(parent.threadId()));\n        var response = request.post(\"pulls/\" + json.get(\"number\").toString() + \"/comments\")\n                              .body(query)\n                              .execute();\n        return parseReviewComment(parent, response.asObject(), true);\n    }\n\n    private List<ReviewComment> reviewComments(boolean includeLocationData) {\n        var ret = new ArrayList<ReviewComment>();\n        var reviewComments = request.get(\"pulls/\" + json.get(\"number\").toString() + \"/comments\")\n                .param(\"per_page\", \"100\").execute().stream()\n                .map(JSONValue::asObject)\n                .collect(Collectors.toList());\n        var idToComment = new HashMap<String, ReviewComment>();\n\n        for (var reviewComment : reviewComments) {\n            ReviewComment parent = null;\n            if (reviewComment.contains(\"in_reply_to_id\")) {\n                parent = idToComment.get(reviewComment.get(\"in_reply_to_id\").toString());\n            }\n            var comment = parseReviewComment(parent, reviewComment, includeLocationData);\n            idToComment.put(comment.id(), comment);\n            ret.add(comment);\n        }\n\n        return ret;\n    }\n\n    @Override\n    public List<ReviewComment> reviewComments() {\n        return reviewComments(true);\n    }\n\n    @Override\n    public List<? extends Comment> reviewCommentsAsComments() {\n        return reviewComments(false);\n    }\n\n    @Override\n    public Hash headHash() {\n        return new Hash(json.get(\"head\").get(\"sha\").asString());\n    }\n\n    @Override\n    public String fetchRef() {\n        return \"pull/\" + id() + \"/head\";\n    }\n\n    @Override\n    public String sourceRef() {\n        return json.get(\"head\").get(\"ref\").asString();\n    }\n\n    @Override\n    public Optional<HostedRepository> sourceRepository() {\n        if (json.get(\"head\").get(\"repo\").isNull()) {\n            return Optional.empty();\n        } else {\n            return Optional.of(new GitHubRepository(host, json.get(\"head\").get(\"repo\").get(\"full_name\").asString()));\n        }\n    }\n\n    @Override\n    public String targetRef() {\n        return json.get(\"base\").get(\"ref\").asString();\n    }\n\n    @Override\n    public String title() {\n        return json.get(\"title\").asString().strip();\n    }\n\n    @Override\n    public void setTitle(String title) {\n        request.patch(\"pulls/\" + json.get(\"number\").toString())\n               .body(\"title\", title)\n               .execute();\n    }\n\n    @Override\n    public String body() {\n        var body = json.get(\"body\").asString();\n        if (body == null) {\n            body = \"\";\n        }\n        return body;\n    }\n\n    @Override\n    public void setBody(String body) {\n        request.patch(\"pulls/\" + json.get(\"number\").toString())\n               .body(\"body\", body)\n               .execute();\n    }\n\n    private Comment parseComment(JSONValue comment) {\n        var ret = new Comment(comment.get(\"id\").toString(),\n                              comment.get(\"body\").asString(),\n                              host.parseUserField(comment),\n                              ZonedDateTime.parse(comment.get(\"created_at\").asString()),\n                              ZonedDateTime.parse(comment.get(\"updated_at\").asString()));\n        return ret;\n    }\n\n    @Override\n    public List<Comment> comments() {\n        return request.get(\"issues/\" + json.get(\"number\").toString() + \"/comments\")\n                .param(\"per_page\", \"100\").execute().stream()\n                .map(this::parseComment)\n                .collect(Collectors.toList());\n    }\n\n    @Override\n    public Comment addComment(String body) {\n        body = limitBodySize(body);\n        var comment = request.post(\"issues/\" + json.get(\"number\").toString() + \"/comments\")\n                .body(\"body\", body)\n                .execute();\n        return parseComment(comment);\n    }\n\n    @Override\n    public void removeComment(Comment comment) {\n        throw new RuntimeException(\"not implemented yet\");\n    }\n\n    @Override\n    public Comment updateComment(String id, String body) {\n        body = limitBodySize(body);\n        var comment = request.patch(\"issues/comments/\" + id)\n                             .body(\"body\", body)\n                             .onError(r -> {\n                                 if (r.statusCode() == 404) {\n                                     return Optional.of(JSON.object().put(\"NOT_FOUND\", true));\n                                 }\n                                 throw new RuntimeException(\"Invalid response\");\n                             })\n                             .execute();\n        if (comment.contains(\"NOT_FOUND\")) {\n            var reviewComment = request.patch(\"pulls/comments/\" + id)\n                                       .body(\"body\", body)\n                                       .execute();\n            return parseReviewComment(null, reviewComment.asObject(), false);\n        }\n        return parseComment(comment);\n    }\n\n    @Override\n    public ZonedDateTime createdAt() {\n        return ZonedDateTime.parse(json.get(\"created_at\").asString());\n    }\n\n    @Override\n    public ZonedDateTime updatedAt() {\n        return ZonedDateTime.parse(json.get(\"updated_at\").asString());\n    }\n\n    @Override\n    public State state() {\n        if (json.get(\"state\").asString().equals(\"open\")) {\n            return State.OPEN;\n        }\n        return State.CLOSED;\n    }\n\n    @Override\n    public Map<String, Check> checks(Hash hash) {\n        var checks = request.get(\"commits/\" + hash.hex() + \"/check-runs\").execute();\n\n        return checks.get(\"check_runs\").stream()\n                .collect(Collectors.toMap(c -> c.get(\"name\").asString(),\n                        c -> {\n                            var checkBuilder = CheckBuilder.create(c.get(\"name\").asString(), new Hash(c.get(\"head_sha\").asString()));\n                            checkBuilder.startedAt(ZonedDateTime.parse(c.get(\"started_at\").asString()));\n\n                            var completed = c.get(\"status\").asString().equals(\"completed\");\n                            if (completed) {\n                                var conclusion = c.get(\"conclusion\").asString();\n                                var completedAtString = c.get(\"completed_at\").asString();\n                                var completedAt = completedAtString != null ? ZonedDateTime.parse(completedAtString) : null;\n                                switch (conclusion) {\n                                    case \"cancelled\" -> checkBuilder.cancel(completedAt);\n                                    case \"success\" -> checkBuilder.complete(true, completedAt);\n                                    case \"action_required\", \"failure\", \"neutral\" -> checkBuilder.complete(false, completedAt);\n                                    case \"skipped\" -> checkBuilder.skipped(completedAt);\n                                    case \"stale\" -> checkBuilder.stale();\n                                    default -> throw new IllegalStateException(\"Unexpected conclusion: \" + conclusion);\n                                }\n                            }\n                            if (c.contains(\"external_id\")) {\n                                checkBuilder.metadata(c.get(\"external_id\").asString());\n                            }\n                            if (c.contains(\"output\")) {\n                                var output = c.get(\"output\").asObject();\n                                if (output.contains(\"title\")) {\n                                    checkBuilder.title(output.get(\"title\").asString());\n                                }\n                                if (output.contains(\"summary\")) {\n                                    checkBuilder.summary(output.get(\"summary\").asString());\n                                }\n                            }\n                            if (c.contains(\"details_url\")) {\n                                checkBuilder.details(URI.create(c.get(\"details_url\").asString()));\n                            }\n\n                            return checkBuilder.build();\n                        }, (a, b) -> b));\n    }\n\n    @Override\n    public void createCheck(Check check) {\n        // update and create are currently identical operations, both do an HTTP\n        // POST to the /repos/:owner/:repo/check-runs endpoint. There is an additional\n        // endpoint explicitly for updating check-runs, but that is not currently used.\n        updateCheck(check);\n    }\n\n    @Override\n    public void updateCheck(Check check) {\n        var completedQuery = JSON.object();\n        completedQuery.put(\"name\", check.name());\n        completedQuery.put(\"head_branch\", json.get(\"head\").get(\"ref\"));\n        completedQuery.put(\"head_sha\", check.hash().hex());\n\n        if (check.title().isPresent() && check.summary().isPresent()) {\n            var outputQuery = JSON.object();\n            outputQuery.put(\"title\", check.title().get());\n            outputQuery.put(\"summary\", check.summary().get());\n\n            var annotations = JSON.array();\n            for (var annotation : check.annotations().subList(0, Math.min(check.annotations().size(), 50))) {\n                var annotationQuery = JSON.object();\n                annotationQuery.put(\"path\", annotation.path());\n                annotationQuery.put(\"start_line\", annotation.startLine());\n                annotationQuery.put(\"end_line\", annotation.endLine());\n                annotation.startColumn().ifPresent(startColumn -> annotationQuery.put(\"start_column\", startColumn));\n                annotation.endColumn().ifPresent(endColumn -> annotationQuery.put(\"end_column\", endColumn));\n                switch (annotation.level()) {\n                    case NOTICE:\n                        annotationQuery.put(\"annotation_level\", \"notice\");\n                        break;\n                    case WARNING:\n                        annotationQuery.put(\"annotation_level\", \"warning\");\n                        break;\n                    case FAILURE:\n                        annotationQuery.put(\"annotation_level\", \"failure\");\n                        break;\n                }\n\n                annotationQuery.put(\"message\", annotation.message());\n                annotation.title().ifPresent(title -> annotationQuery.put(\"title\", title));\n                annotations.add(annotationQuery);\n            }\n\n            outputQuery.put(\"annotations\", annotations);\n            completedQuery.put(\"output\", outputQuery);\n        }\n\n        if (check.status() == CheckStatus.IN_PROGRESS) {\n            completedQuery.put(\"status\", \"in_progress\");\n        } else {\n            completedQuery.put(\"status\", \"completed\");\n            completedQuery.put(\"conclusion\", check.status().name().toLowerCase());\n            completedQuery.put(\"completed_at\", check.completedAt().orElse(ZonedDateTime.now(ZoneOffset.UTC))\n                    .format(DateTimeFormatter.ISO_INSTANT));\n        }\n\n        completedQuery.put(\"started_at\", check.startedAt().format(DateTimeFormatter.ISO_INSTANT));\n        check.metadata().ifPresent(metadata -> completedQuery.put(\"external_id\", metadata));\n\n        request.post(\"check-runs\").body(completedQuery).execute();\n    }\n\n    @Override\n    public URI changeUrl() {\n        return URIBuilder.base(webUrl()).appendPath(\"/files\").build();\n    }\n\n    @Override\n    public URI changeUrl(Hash base) {\n        return URIBuilder.base(webUrl()).appendPath(\"/files/\" + base.abbreviate() + \"..\" + headHash().abbreviate()).build();\n    }\n\n    @Override\n    public URI commentUrl(Comment comment) {\n        return URIBuilder.base(webUrl()).appendPath(\"#issuecomment-\" + comment.id()).build();\n    }\n\n    @Override\n    public URI reviewCommentUrl(ReviewComment reviewComment) {\n        return URIBuilder.base(webUrl()).appendPath(\"#discussion_r\" + reviewComment.id()).build();\n    }\n\n    @Override\n    public URI reviewUrl(Review review) {\n        return URIBuilder.base(webUrl()).appendPath(\"#pullrequestreview-\" + review.id()).build();\n    }\n\n    @Override\n    public boolean isDraft() {\n        return json.get(\"draft\").asBoolean();\n    }\n\n    @Override\n    public void setState(State state) {\n        request.patch(\"pulls/\" + json.get(\"number\").toString())\n               .body(\"state\", state != State.OPEN ? \"closed\" : \"open\")\n               .execute();\n    }\n\n    @Override\n    public void addLabel(String label) {\n        labels = null;\n        var query = JSON.object().put(\"labels\", JSON.array().add(label));\n        request.post(\"issues/\" + json.get(\"number\").toString() + \"/labels\")\n               .body(query)\n               .execute();\n    }\n\n    @Override\n    public void removeLabel(String label) {\n        labels = null;\n        request.delete(\"issues/\" + json.get(\"number\").toString() + \"/labels/\" + label)\n               .onError(r -> {\n                   // The GitHub API explicitly states that 404 is the response for deleting labels currently not set\n                   if (r.statusCode() == 404) {\n                       return Optional.of(JSONValue.fromNull());\n                   }\n                   throw new RuntimeException(\"Invalid response\");\n               })\n               .execute();\n    }\n\n    @Override\n    public void setLabels(List<String> labels) {\n        var labelArray = JSON.array();\n        for (var label : labels) {\n            labelArray.add(label);\n        }\n        var query = JSON.object().put(\"labels\", labelArray);\n        var newLabels = request.put(\"issues/\" + json.get(\"number\").toString() + \"/labels\")\n                               .body(query)\n                               .execute()\n                               .stream()\n                               .map(o -> new Label(o.get(\"name\").asString(), o.get(\"description\").asString()))\n                               .collect(Collectors.toList());\n        this.labels = newLabels;\n    }\n\n    @Override\n    public List<Label> labels() {\n        if (labels == null) {\n            labels = request.get(\"issues/\" + json.get(\"number\").toString() + \"/labels\").execute().stream()\n                            .map(JSONValue::asObject)\n                            .map(obj -> new Label(obj.get(\"name\").asString(), obj.get(\"description\").asString()))\n                            .sorted()\n                            .collect(Collectors.toList());\n        }\n        return labels;\n    }\n\n    private URI getWebUrl(boolean transform) {\n        var host = (GitHubHost)repository.forge();\n        var endpoint = \"/\" + repository.name() + \"/pull/\" + id();\n        return host.getWebURI(endpoint, transform);\n    }\n\n    @Override\n    public URI webUrl() {\n        return getWebUrl(true);\n    }\n\n    @Override\n    public URI nonTransformedWebUrl() {\n        return getWebUrl(false);\n    }\n\n    @Override\n    public String toString() {\n        return \"GitHubPullRequest #\" + id() + \" by \" + author();\n    }\n\n    @Override\n    public List<HostUser> assignees() {\n        return json.get(\"assignees\").asArray()\n                                    .stream()\n                                    .map(host::parseUserObject)\n                                    .collect(Collectors.toList());\n    }\n\n    @Override\n    public void setAssignees(List<HostUser> assignees) {\n        var assignee_ids = JSON.array();\n        for (var assignee : assignees) {\n            assignee_ids.add(assignee.username());\n        }\n        var param = JSON.object().put(\"assignees\", assignee_ids);\n        request.patch(\"issues/\" + json.get(\"number\").toString()).body(param).execute();\n    }\n\n    @Override\n    public void makeNotDraft() {\n        if (!isDraft()) {\n            return;\n        }\n\n        var parts = repository.name().split(\"/\");\n        var owner = parts[0];\n        var name = parts[1];\n        var number = id();\n\n        var query = String.join(\"\\n\", List.of(\n            \"query {\",\n            \"    repository(owner: \\\"\" + owner + \"\\\", name: \\\"\" + name + \"\\\") {\",\n            \"        pullRequest(number: \" + number + \") {\",\n            \"            id\",\n            \"        }\",\n            \"    }\",\n            \"}\"\n        ));\n        var data = host.graphQL()\n                       .post()\n                       .body(JSON.object().put(\"query\", query))\n                       .execute()\n                       .get(\"data\");\n        var prId = data.get(\"repository\")\n                            .get(\"pullRequest\")\n                            .get(\"id\").asString();\n\n        var input = \"{pullRequestId:\\\"\" + prId + \"\\\"}\";\n        // Do not care about the returned PR id, but the markPullRequestReadyForReview\n        // mutation requires non-nullable selection.\n        var mutation = String.join(\"\\n\", List.of(\n            \"mutation {\",\n            \"    markPullRequestReadyForReview(input: \" + input + \") {\",\n            \"        pullRequest {\",\n            \"            id\",\n            \"        }\",\n            \"    }\",\n            \"}\"\n        ));\n        host.graphQL()\n            .post()\n            .body(JSON.object().put(\"query\", mutation))\n            .execute();\n    }\n\n    @Override\n    public Optional<ZonedDateTime> lastMarkedAsDraftTime() {\n        var lastMarkedAsDraftTime = request.get(\"issues/\" + json.get(\"number\").toString() + \"/timeline\")\n                .execute().stream()\n                .map(JSONValue::asObject)\n                .filter(obj -> obj.contains(\"event\"))\n                .filter(obj -> obj.get(\"event\").asString().equals(\"convert_to_draft\"))\n                .map(obj -> ZonedDateTime.parse(obj.get(\"created_at\").asString()))\n                .max(ZonedDateTime::compareTo);\n        if (lastMarkedAsDraftTime.isEmpty() && isDraft()) {\n            return Optional.of(createdAt());\n        }\n        return lastMarkedAsDraftTime;\n    }\n\n    @Override\n    public URI diffUrl() {\n        return URI.create(webUrl() + \".diff\");\n    }\n\n    @Override\n    public Optional<ZonedDateTime> labelAddedAt(String label) {\n        return request.get(\"issues/\" + json.get(\"number\").toString() + \"/timeline\")\n                      .execute()\n                      .stream()\n                      .map(JSONValue::asObject)\n                      .filter(obj -> obj.contains(\"event\"))\n                      .filter(obj -> obj.get(\"event\").asString().equals(\"labeled\"))\n                      .filter(obj -> obj.get(\"label\").get(\"name\").asString().equals(label))\n                      .map(o -> ZonedDateTime.parse(o.get(\"created_at\").asString()))\n                      .findFirst();\n    }\n\n    @Override\n    public void setTargetRef(String targetRef) {\n        request.patch(\"pulls/\" + json.get(\"number\").toString())\n               .body(\"base\", targetRef)\n               .execute();\n    }\n\n    @Override\n    public URI headUrl() {\n        return URI.create(webUrl() + \"/commits/\" + headHash().hex());\n    }\n\n    @Override\n    public Diff diff() {\n        // Need to specify an explicit per_page < 70 to guarantee that we get patch information in the result set.\n        var files = request.get(\"pulls/\" + json.get(\"number\").toString() + \"/files\")\n                           .param(\"per_page\", \"50\")\n                           .execute();\n        var targetHash = repository.branchHash(targetRef()).orElseThrow();\n        var complete = files.asArray().size() == json.get(\"changed_files\").asInt();\n        return repository.toDiff(targetHash, headHash(), files, complete);\n    }\n\n    @Override\n    public Optional<HostUser> closedBy() {\n        if (!isClosed()) {\n            return Optional.empty();\n        }\n\n        return request.get(\"issues/\" + json.get(\"number\").toString() + \"/timeline\")\n                      .execute()\n                      .stream()\n                      .map(JSONValue::asObject)\n                      .filter(obj -> obj.contains(\"event\"))\n                      .filter(obj -> obj.get(\"event\").asString().equals(\"closed\"))\n                      .max(Comparator.comparing(o -> ZonedDateTime.parse(o.get(\"created_at\").asString())))\n                      .map(e -> host.parseUserObject(e.get(\"actor\")));\n    }\n\n    @Override\n    public URI filesUrl(Hash hash) {\n        var endpoint = \"/\" + repository.name() + \"/pull/\" + id() + \"/files/\" + hash.hex();\n        return host.getWebURI(endpoint);\n    }\n\n    @Override\n    public Optional<ZonedDateTime> lastForcePushTime() {\n        var timelineJSON = request.get(\"issues/\" + json.get(\"number\").toString() + \"/timeline\")\n                .execute();\n        return timelineJSON\n                .stream()\n                .map(JSONValue::asObject)\n                .filter(obj -> obj.contains(\"event\"))\n                .filter(obj -> obj.get(\"event\").asString().equals(\"head_ref_force_pushed\"))\n                .filter(obj -> ZonedDateTime.parse(obj.get(\"created_at\").asString()).isAfter(lastMarkedAsReadyTime(timelineJSON)))\n                .map(obj -> ZonedDateTime.parse(obj.get(\"created_at\").asString()))\n                .max(Comparator.naturalOrder());\n    }\n\n    @Override\n    public Optional<Hash> findIntegratedCommitHash() {\n        return findIntegratedCommitHash(List.of(repository.forge().currentUser().id()));\n    }\n\n    /**\n     * For GitHubPullRequest, the json represents the complete snapshot\n     */\n    @Override\n    public Object snapshot() {\n        return json;\n    }\n\n    public String limitBodySize(String body) {\n        if (body.length() > GITHUB_PR_COMMENT_BODY_MAX_SIZE) {\n            return body.substring(0, GITHUB_PR_COMMENT_BODY_MAX_SIZE)\n                    + \"...\";\n        }\n        return body;\n    }\n\n    private ZonedDateTime lastMarkedAsReadyTime(JSONValue timelineJSON) {\n        return timelineJSON\n                .stream()\n                .map(JSONValue::asObject)\n                .filter(obj -> obj.contains(\"event\"))\n                .filter(obj -> obj.get(\"event\").asString().equals(\"ready_for_review\"))\n                .map(obj -> ZonedDateTime.parse(obj.get(\"created_at\").asString()))\n                .max(ZonedDateTime::compareTo)\n                .orElseGet(this::createdAt);\n    }\n\n    @Override\n    public ZonedDateTime lastTouchedTime() {\n        Set<String> relevantEvents = Set.of(\"committed\", \"reopened\", \"ready_for_review\", \"convert_to_draft\");\n\n        return request.get(\"issues/\" + json.get(\"number\").toString() + \"/timeline\")\n                .execute().stream()\n                .map(JSONValue::asObject)\n                .filter(obj -> obj.contains(\"event\"))\n                .filter(obj -> relevantEvents.contains(obj.get(\"event\").asString()))\n                .map(obj -> obj.get(\"event\").asString().equals(\"committed\") ? obj.get(\"committer\").get(\"date\").asString() : obj.get(\"created_at\").asString())\n                .map(ZonedDateTime::parse)\n                .max(ZonedDateTime::compareTo)\n                .orElseGet(this::createdAt);\n    }\n\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/github/GitHubRepository.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.github;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.time.Duration;\nimport java.util.logging.Logger;\nimport java.util.regex.Matcher;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Label;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.network.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.net.URI;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Path;\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class GitHubRepository implements HostedRepository {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.forge.github\");\n    private final GitHubHost gitHubHost;\n    private final String repository;\n    private final RestRequest request;\n    private final List<Pattern> pullRequestPatterns;\n\n    private JSONValue cachedJSON;\n    private List<HostedBranch> branches;\n\n    GitHubRepository(GitHubHost gitHubHost, String repository, JSONValue json) {\n        this(gitHubHost, repository);\n        cachedJSON = json;\n    }\n\n    GitHubRepository(GitHubHost gitHubHost, String repository) {\n        this.gitHubHost = gitHubHost;\n        this.repository = repository;\n\n        var apiBase = URIBuilder\n                .base(gitHubHost.getURI())\n                .appendSubDomain(\"api\")\n                .setPath(\"/repos/\" + repository + \"/\")\n                .build();\n        request = new RestRequest(apiBase, gitHubHost.authId().orElse(null), (r) -> {\n            var headers = new ArrayList<>(List.of(\n                \"X-GitHub-Api-Version\", \"2022-11-28\"));\n            var token = gitHubHost.getInstallationToken();\n            if (token.isPresent()) {\n                headers.add(\"Authorization\");\n                headers.add(\"token \" + token.get());\n            }\n            return headers;\n        });\n        var urlPatterns = gitHubHost.getAllWebURIs(\"/\" + repository + \"/pull/\");\n        pullRequestPatterns = urlPatterns.stream()\n                .map(u -> Pattern.compile(u + \"(\\\\d+)\"))\n                .toList();\n    }\n\n    private JSONValue json() {\n        if (cachedJSON == null) {\n            cachedJSON = gitHubHost.getProjectInfo(repository)\n                    .orElseThrow(() -> new RuntimeException(\"Repository not found: \" + repository));\n        }\n        return cachedJSON;\n    }\n\n    boolean multipleBranches() {\n        if (branches == null) {\n            branches = branches();\n        }\n        return branches.size() > 1;\n    }\n\n    @Override\n    public Optional<HostedRepository> parent() {\n        if (json().get(\"fork\").asBoolean()) {\n            var parent = json().get(\"parent\").get(\"full_name\").asString();\n            return Optional.of(new GitHubRepository(gitHubHost, parent));\n        }\n        return Optional.empty();\n    }\n\n    @Override\n    public Forge forge() {\n        return gitHubHost;\n    }\n\n    @Override\n    public PullRequest createPullRequest(HostedRepository target,\n                                         String targetRef,\n                                         String sourceRef,\n                                         String title,\n                                         List<String> body,\n                                         boolean draft) {\n        if (!(target instanceof GitHubRepository upstream)) {\n            throw new IllegalArgumentException(\"target repository must be a GitHub repository\");\n        }\n\n        var params = JSON.object()\n                         .put(\"title\", title)\n                         .put(\"head\", group() + \":\" + sourceRef)\n                         .put(\"base\", targetRef)\n                         .put(\"body\", String.join(\"\\n\", body))\n                         .put(\"draft\", draft);\n        var pr = upstream.request.post(\"pulls\")\n                                 .body(params)\n                                 .execute();\n\n        return new GitHubPullRequest(upstream, pr, upstream.request);\n    }\n\n    @Override\n    public PullRequest pullRequest(String id) {\n        var pr = request.get(\"pulls/\" + id).execute();\n        return new GitHubPullRequest(this, pr, request);\n    }\n\n    @Override\n    public List<PullRequest> pullRequests() {\n        return request.get(\"pulls\")\n                      .param(\"state\", \"all\")\n                      .param(\"sort\", \"updated\")\n                      .param(\"direction\", \"desc\")\n                      .maxPages(1)\n                      .execute().asArray().stream()\n                      .map(jsonValue -> new GitHubPullRequest(this, jsonValue, request))\n                      .collect(Collectors.toList());\n    }\n\n    @Override\n    public List<PullRequest> openPullRequests() {\n        return request.get(\"pulls\")\n                      .param(\"state\", \"open\")\n                      .execute().asArray().stream()\n                      .map(jsonValue -> new GitHubPullRequest(this, jsonValue, request))\n                      .collect(Collectors.toList());\n    }\n\n    @Override\n    public List<PullRequest> pullRequestsAfter(ZonedDateTime updatedAfter) {\n        List<PullRequest> prs = request.get(\"pulls\")\n                .param(\"state\", \"all\")\n                .param(\"sort\", \"updated\")\n                .param(\"direction\", \"desc\")\n                .param(\"per_page\", \"100\")\n                .maxPages(2)\n                .execute().asArray().stream()\n                .map(jsonValue -> new GitHubPullRequest(this, jsonValue, request))\n                .filter(pr -> !pr.updatedAt().isBefore(updatedAfter))\n                .collect(Collectors.toList());\n        // Check if any PRs were returned out of order and log it to keep track\n        // of if GitHub ever stops this insanity.\n        PullRequest previousPr = null;\n        for (PullRequest pr : prs) {\n            if (previousPr != null && previousPr.updatedAt().isBefore(pr.updatedAt())) {\n                log.info(\"GitHub PR for \" + name() + \" listed out of order: \" + pr.id() + \" updatedAt: \" + pr.updatedAt()\n                        + \" listed after \" + previousPr.id() + \" updatedAt: \" + previousPr.updatedAt());\n            } else {\n                previousPr = pr;\n            }\n        }\n        return prs;\n    }\n\n    @Override\n    public List<PullRequest> openPullRequestsAfter(ZonedDateTime updatedAfter) {\n        return request.get(\"pulls\")\n                .param(\"state\", \"open\")\n                .execute().asArray().stream()\n                .map(jsonValue -> new GitHubPullRequest(this, jsonValue, request))\n                .filter(pr -> !pr.updatedAt().isBefore(updatedAfter))\n                .collect(Collectors.toList());\n    }\n\n    @Override\n    public List<PullRequest> findPullRequestsWithComment(String author, String body) {\n        var query = \"\\\"\" + body + \"\\\" in:comments type:pr repo:\" + repository;\n        if (author != null) {\n            query += \" commenter:\" + author;\n        }\n        var result = gitHubHost.runSearch(\"issues\", query);\n        return result.get(\"items\").stream()\n                     .map(jsonValue -> jsonValue.get(\"number\").asInt())\n                     .map(id -> pullRequest(id.toString()))\n                     .collect(Collectors.toList());\n    }\n\n    @Override\n    public Optional<PullRequest> parsePullRequestUrl(String url) {\n        return pullRequestPatterns.stream()\n                .map(p -> p.matcher(url))\n                .filter(Matcher::find)\n                .map(m -> pullRequest(m.group(1)))\n                .findAny();\n    }\n\n    @Override\n    public String name() {\n        return repository;\n    }\n\n    @Override\n    public String group() {\n        return repository.split(\"/\")[0];\n    }\n\n    @Override\n    public URI authenticatedUrl() {\n        var builder = URIBuilder.base(gitHubHost.getURI())\n                                .setPath(\"/\" + repository + \".git\");\n        var token = gitHubHost.getInstallationToken();\n        if (token.isPresent()) {\n            builder.setAuthentication(\"x-access-token:\" + token.get());\n        }\n        return builder.build();\n    }\n\n    @Override\n    public URI webUrl() {\n        var endpoint = \"/\" + repository;\n        return gitHubHost.getWebURI(endpoint);\n    }\n\n    @Override\n    public URI nonTransformedWebUrl() {\n        var endpoint = \"/\" + repository;\n        return gitHubHost.getWebURI(endpoint, false);\n    }\n\n    @Override\n    public URI webUrl(Hash hash) {\n        var endpoint = \"/\" + repository + \"/commit/\" + hash;\n        return gitHubHost.getWebURI(endpoint);\n    }\n\n    @Override\n    public URI webUrl(String baseRef, String headRef) {\n        var endpoint = \"/\" + repository + \"/compare/\" + baseRef + \"...\" + headRef;\n        return gitHubHost.getWebURI(endpoint);\n    }\n\n    @Override\n    public URI diffUrl(String prId) {\n        var endpoint = \"/\" + repository + \"/pull/\" + prId + \".diff\";\n        return gitHubHost.getWebURI(endpoint);\n    }\n\n    @Override\n    public VCS repositoryType() {\n        return VCS.GIT;\n    }\n\n    @Override\n    public URI url() {\n        var endpoint = \"/\" + repository + \".git\";\n        return gitHubHost.getWebURI(endpoint);\n    }\n\n    @Override\n    public Optional<String> fileContents(String filename, String ref) {\n        // Get file contents using raw format. This allows us to get files of\n        // size up to 100MB (up from 1MB if getting in object from).\n        try {\n            var content = request.get(\"contents/\" + filename)\n                    .param(\"ref\", ref)\n                    .header(\"Accept\", \"application/vnd.github.raw+json\")\n                    .executeUnparsed();\n            return Optional.of(content);\n        } catch (UncheckedRestException e) {\n            // The onError handler is not used with executeUnparsed, so have to\n            // resort to catching exception for 404 handling.\n            // For GitHub, if ref not found, it returns \"No commit found for the ref \",\n            // if file not found, it returns \"Not Found\".\n            if (e.getStatusCode() == 404 && e.getMessage().contains(\"Not Found\")) {\n                return Optional.empty();\n            } else {\n                throw e;\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public void writeFileContents(String filename, String content, Branch branch, String message, String authorName, String authorEmail, boolean createNewFile) {\n        var body = JSON.object()\n                .put(\"message\", message)\n                .put(\"branch\", branch.name())\n                .put(\"committer\", JSON.object()\n                        .put(\"name\", authorName)\n                        .put(\"email\", authorEmail))\n                .put(\"content\", new String(Base64.getEncoder().encode(content.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8));\n\n        // If the file exists, we have to supply the current sha with the update request.\n        if (!createNewFile) {\n            var currentFileData = request.get(\"contents/\" + filename)\n                    .param(\"ref\", branch.name())\n                    .onError(r -> r.statusCode() == 404 ? Optional.of(JSON.object().put(\"NOT_FOUND\", true)) : Optional.empty())\n                    .execute();\n            if (currentFileData.contains(\"sha\")) {\n                body.put(\"sha\", currentFileData.get(\"sha\").asString());\n            }\n        }\n\n        request.put(\"contents/\" + filename)\n                .body(body)\n                .execute();\n    }\n\n    @Override\n    public String namespace() {\n        return URIBuilder.base(gitHubHost.getURI()).build().getHost();\n    }\n\n    @Override\n    public Optional<WebHook> parseWebHook(JSONValue body) {\n        throw new RuntimeException(\"not implemented yet\");\n    }\n\n    @Override\n    public HostedRepository fork() {\n        var response = request.post(\"forks\").execute();\n        return gitHubHost.repository(response.get(\"full_name\").asString()).orElseThrow(RuntimeException::new);\n    }\n\n    @Override\n    public long id() {\n        return json().get(\"id\").asLong();\n    }\n\n    @Override\n    public Optional<Hash> branchHash(String ref) {\n        var branch = request.get(\"branches/\" + ref)\n                .onError(r -> r.statusCode() == 404 ? Optional.of(JSON.object().put(\"NOT_FOUND\", true)) : Optional.empty())\n                .execute();\n        if (branch.contains(\"NOT_FOUND\")) {\n            return Optional.empty();\n        }\n        return Optional.of(new Hash(branch.get(\"commit\").get(\"sha\").asString()));\n    }\n\n    @Override\n    public List<HostedBranch> branches() {\n        var branches = request.get(\"branches\").execute();\n        return branches.stream()\n                       .map(b -> new HostedBranch(b.get(\"name\").asString(),\n                                                  new Hash(b.get(\"commit\").get(\"sha\").asString())))\n                       .collect(Collectors.toList());\n    }\n\n    @Override\n    public String defaultBranchName() {\n        return json().get(\"default_branch\").asString();\n    }\n\n    @Override\n    public void protectBranchPattern(String pattern) {\n        // This could be implemented using GraphQL, but we currently don't need it for GitHub\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void unprotectBranchPattern(String pattern) {\n        // This could be implemented using GraphQL, but we currently don't need it for GitHub\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void deleteBranch(String ref) {\n        request.delete(\"git/refs/heads/\" + ref)\n               .execute();\n    }\n\n    private CommitComment toCommitComment(JSONValue o) {\n        var hash = new Hash(o.get(\"commit_id\").asString());\n        var line = o.get(\"line\").isNull()? -1 : o.get(\"line\").asInt();\n        var path = o.get(\"path\").isNull()? null : Path.of(o.get(\"path\").asString());\n        return new CommitComment(hash,\n                                 path,\n                                 line,\n                                 o.get(\"id\").toString(),\n                                 o.get(\"body\").asString(),\n                                 gitHubHost.parseUserField(o),\n                                 ZonedDateTime.parse(o.get(\"created_at\").asString()),\n                                 ZonedDateTime.parse(o.get(\"updated_at\").asString()));\n    }\n\n    @Override\n    public List<CommitComment> commitComments(Hash hash) {\n        return request.get(\"commits/\" + hash.hex() + \"/comments\")\n                      .execute()\n                      .stream()\n                      .map(this::toCommitComment)\n                      .collect(Collectors.toList());\n    }\n\n    @Override\n    public List<CommitComment> recentCommitComments(ReadOnlyRepository unused, Set<Integer> excludeAuthors,\n            List<Branch> branches, ZonedDateTime updatedAfter) {\n        var parts = name().split(\"/\");\n        var owner = parts[0];\n        var name = parts[1];\n\n        var query = String.join(\"\\n\", List.of(\n            \"{\",\n            \"  repository(owner: \\\"\" + owner + \"\\\", name: \\\"\" + name + \"\\\") {\",\n            \"    commitComments(last: 100) {\",\n            \"      nodes {\",\n            \"        createdAt,\",\n            \"        updatedAt,\",\n            \"        author {,\",\n            \"          login,\",\n            \"          __typename,\",\n            \"          ... on Bot {\",\n            \"            databaseId\",\n            \"          },\",\n            \"          ... on User {\",\n            \"            databaseId\",\n            \"          },\",\n            \"        },\",\n            \"        databaseId,\",\n            \"        commit { oid },\",\n            \"        body\",\n            \"      }\",\n            \"    }\",\n            \"  }\",\n            \"}\"\n        ));\n\n        var data = gitHubHost.graphQL()\n                             .post()\n                             .body(JSON.object().put(\"query\", query))\n                             // This is a single point graphql query so shouldn't need to be limited to once a second\n                             .skipLimiter(true)\n                             .execute()\n                             .get(\"data\");\n        var comments = data.get(\"repository\")\n                           .get(\"commitComments\")\n                           .get(\"nodes\")\n                           .stream()\n                           .filter(o -> !excludeAuthors.contains(o.get(\"author\").get(\"databaseId\").asInt()))\n                           .map(o -> {\n                               var hash = new Hash(o.get(\"commit\").get(\"oid\").asString());\n                               var createdAt = ZonedDateTime.parse(o.get(\"createdAt\").asString());\n                               var updatedAt = ZonedDateTime.parse(o.get(\"updatedAt\").asString());\n                               var id = String.valueOf(o.get(\"databaseId\").asInt());\n                               var body = o.get(\"body\").asString();\n                               var username = o.get(\"author\").get(\"login\").asString();\n                               var typename = o.get(\"author\").get(\"__typename\").asString();\n                               if (typename.equals(\"Bot\")) {\n                                   username += \"[bot]\";\n                               }\n                               var userid = o.get(\"author\").get(\"databaseId\").asInt();\n                               var user = gitHubHost.hostUser(userid, username);\n                               return new CommitComment(hash,\n                                                        null,\n                                                        -1,\n                                                        id,\n                                                        body,\n                                                        user,\n                                                        createdAt,\n                                                        updatedAt);\n                           })\n                           // It's not possible to filter on timestamp in the GraphQL API, but we\n                           // can at least filter here to limit the amount of data returned to the\n                           // caller.\n                           .filter(c -> c.updatedAt().isAfter(updatedAfter))\n                           .collect(Collectors.toList());\n        Collections.reverse(comments);\n        return comments;\n    }\n\n    @Override\n    public CommitComment addCommitComment(Hash hash, String body) {\n        var query = JSON.object().put(\"body\", body);\n        var result = request.post(\"commits/\" + hash.hex() + \"/comments\")\n                            .body(query)\n                            .execute();\n        return toCommitComment(result);\n    }\n\n    @Override\n    public void updateCommitComment(String id, String body) {\n        var query = JSON.object().put(\"body\", body);\n        request.patch(\"comments/\" + id)\n               .body(query)\n               .execute();\n    }\n\n    private CommitMetadata toCommitMetadata(JSONValue o) {\n        var hash = new Hash(o.get(\"sha\").asString());\n        var parents = o.get(\"parents\").stream()\n                                      .map(p -> new Hash(p.get(\"sha\").asString()))\n                                      .collect(Collectors.toList());\n        var commit = o.get(\"commit\").asObject();\n        var author = new Author(commit.get(\"author\").get(\"name\").asString(),\n                                commit.get(\"author\").get(\"email\").asString());\n        var authored = ZonedDateTime.parse(commit.get(\"author\").get(\"date\").asString());\n        var committer = new Author(commit.get(\"committer\").get(\"name\").asString(),\n                                   commit.get(\"committer\").get(\"email\").asString());\n        var committed = ZonedDateTime.parse(commit.get(\"committer\").get(\"date\").asString());\n        var message = Arrays.asList(commit.get(\"message\").asString().split(\"\\n\"));\n        return new CommitMetadata(hash, parents, author, authored, committer, committed, message);\n    }\n\n    private Status toStatus(String status) {\n        switch (status) {\n            case \"modified\":\n                return Status.from('M');\n            case \"removed\":\n                return Status.from('D');\n            case \"added\":\n                return Status.from('A');\n            case \"renamed\":\n                return Status.from('R');\n            case \"copied\":\n                return Status.from('C');\n            default:\n                throw new IllegalArgumentException(\"Unexpected status: \" + status);\n        }\n    }\n\n    Diff toDiff(Hash from, Hash to, JSONValue files, boolean complete) {\n        var patches = new ArrayList<Patch>();\n\n        for (var file : files.asArray()) {\n            var status = toStatus(file.get(\"status\").asString());\n            var targetPath = Path.of(file.get(\"filename\").asString());\n            var sourcePath = status.isRenamed() || status.isCopied() ?\n                Path.of(file.get(\"previous_filename\").asString()) :\n                targetPath;\n            var filetype = FileType.fromOctal(\"100644\");\n\n            var hunks = List.<Hunk>of();\n            if (file.contains(\"patch\")) {\n                var diff = file.get(\"patch\").asString().split(\"\\n\");\n                hunks = UnifiedDiffParser.parseSingleFileDiff(diff);\n            }\n\n            patches.add(new TextualPatch(sourcePath, filetype, Hash.zero(),\n                                         targetPath, filetype, Hash.zero(),\n                                         status, hunks));\n        }\n\n        return new Diff(from, to, patches, complete);\n    }\n\n    @Override\n    public Optional<HostedCommit> commit(Hash hash, boolean includeDiffs) {\n        var queryBuilder = request.get(\"commits/\" + hash.hex())\n                .onError(r -> Optional.of(JSON.of()));\n        if (includeDiffs) {\n            // Need to specify an explicit per_page < 70 to guarantee that we get patch information in the result set.\n            queryBuilder.param(\"per_page\", \"50\");\n        } else {\n            // Minimize size of response when diffs aren't needed.\n            queryBuilder\n                    .param(\"per_page\", \"1\")\n                    .maxPages(1);\n        }\n\n        var o = queryBuilder.execute();\n\n        if (o.isNull()) {\n            return Optional.empty();\n        }\n\n        var metadata = toCommitMetadata(o);\n        List<Diff> diffs;\n        if (includeDiffs) {\n            var totalAdditions = o.get(\"stats\").get(\"additions\").asInt();\n            var totalDeletions = o.get(\"stats\").get(\"deletions\").asInt();\n            var sumAdditions = 0;\n            var sumDeletions = 0;\n            for (var patch : o.get(\"files\").asArray()) {\n                sumAdditions += patch.get(\"additions\").asInt();\n                sumDeletions += patch.get(\"deletions\").asInt();\n            }\n            var complete = totalAdditions == sumAdditions && totalDeletions == sumDeletions;\n            diffs = List.of(toDiff(metadata.parents().get(0), hash, o.get(\"files\"), complete));\n        } else {\n            diffs = List.of();\n        }\n        var url = URI.create(o.get(\"html_url\").asString());\n        var webUrl = gitHubHost.getWebURI(url.getPath());\n        return Optional.of(new HostedCommit(metadata, diffs, url, webUrl));\n    }\n\n    @Override\n    public List<Check> allChecks(Hash hash) {\n        var checks = request.get(\"commits/\" + hash.hex() + \"/check-runs\").execute();\n\n        return checks.get(\"check_runs\").stream()\n                     .map(c -> {\n                         var checkBuilder = CheckBuilder.create(c.get(\"name\").asString(), new Hash(c.get(\"head_sha\").asString()));\n                         checkBuilder.startedAt(ZonedDateTime.parse(c.get(\"started_at\").asString()));\n\n                         var completed = c.get(\"status\").asString().equals(\"completed\");\n                         if (completed) {\n                             var conclusion = c.get(\"conclusion\").asString();\n                             var completedAt = ZonedDateTime.parse(c.get(\"completed_at\").asString());\n                             switch (conclusion) {\n                                 case \"cancelled\" -> checkBuilder.cancel(completedAt);\n                                 case \"success\" -> checkBuilder.complete(true, completedAt);\n                                 case \"action_required\", \"failure\", \"neutral\" -> checkBuilder.complete(false, completedAt);\n                                 case \"skipped\" -> checkBuilder.skipped(completedAt);\n                                 default -> throw new IllegalStateException(\"Unexpected conclusion: \" + conclusion);\n                             }\n                         }\n                         if (c.contains(\"external_id\")) {\n                             checkBuilder.metadata(c.get(\"external_id\").asString());\n                         }\n                         if (c.contains(\"output\")) {\n                             var output = c.get(\"output\").asObject();\n                             if (output.contains(\"title\")) {\n                                 checkBuilder.title(output.get(\"title\").asString());\n                             }\n                             if (output.contains(\"summary\")) {\n                                 checkBuilder.summary(output.get(\"summary\").asString());\n                             }\n                         }\n                         if (c.contains(\"details_url\")) {\n                             checkBuilder.details(URI.create(c.get(\"details_url\").asString()));\n                         }\n\n                         return checkBuilder.build(); }\n                     )\n                     .collect(Collectors.toList());\n    }\n\n    @Override\n    public WorkflowStatus workflowStatus() {\n        var workflows = request.get(\"actions/workflows\").execute();\n        var count = workflows.asObject().get(\"total_count\").asInt();\n        if (count == 0) {\n            return WorkflowStatus.NOT_CONFIGURED;\n        } else {\n            return WorkflowStatus.ENABLED;\n        }\n    }\n\n    @Override\n    public URI webUrl(Branch branch) {\n        var endpoint = \"/\" + repository + \"/tree/\" + branch.name();\n        return gitHubHost.getWebURI(endpoint);\n    }\n\n    @Override\n    public URI webUrl(Tag tag) {\n        var endpoint = \"/\" + repository + \"/releases/tag/\" + tag.name();\n        return gitHubHost.getWebURI(endpoint);\n    }\n\n    @Override\n    public URI createPullRequestUrl(HostedRepository target, String targetRef, String sourceRef) {\n        var sourceGroup = repository.split(\"/\")[0];\n        var endpoint = \"/\" + target.name() + \"/pull/\" + targetRef + \"...\" + sourceGroup + \":\" + sourceRef;\n        return gitHubHost.getWebURI(endpoint);\n    }\n\n    @Override\n    public List<Collaborator> collaborators() {\n        var result = request.get(\"collaborators\")\n                .param(\"affiliation\", \"direct\")\n                .execute();\n        return result.stream()\n                .map(o -> new Collaborator(GitHubHost.toHostUser(o.asObject()), o.get(\"permissions\").get(\"push\").asBoolean()))\n                .toList();\n    }\n\n    @Override\n    public void addCollaborator(HostUser user, boolean canPush) {\n        var query = JSON.object().put(\"permission\", canPush ? \"push\" : \"pull\");\n        request.put(\"collaborators/\" + user.username())\n               .body(query)\n               .execute();\n\n    }\n\n    @Override\n    public void removeCollaborator(HostUser user) {\n        request.delete(\"collaborators/\" + user.username()).execute();\n    }\n\n    @Override\n    public boolean canPush(HostUser user) {\n        var permission = request.get(\"collaborators/\" + user.username() + \"/permission\")\n                                .onError(r -> r.statusCode() == 404 ?\n                                                  Optional.of(JSON.object().put(\"permission\", \"none\")) :\n                                                  Optional.empty())\n                                .execute()\n                                .get(\"permission\")\n                                .asString();\n        return permission.equals(\"admin\") || permission.equals(\"write\");\n    }\n\n    @Override\n    public void restrictPushAccess(Branch branch, HostUser user) {\n        var restrictions =\n            JSON.object()\n                .put(\"users\", JSON.array().add(user.username()))\n                .put(\"teams\", JSON.array())\n                .put(\"apps\", JSON.array());\n        var query =\n            JSON.object()\n                .put(\"required_status_checks\", JSON.of())\n                .put(\"enforce_admins\", JSON.of())\n                .put(\"required_pull_request_reviews\", JSON.of())\n                .put(\"restrictions\", restrictions);\n\n        request.put(\"branches/\" + branch.name() + \"/protection\")\n               .body(query)\n               .execute();\n    }\n\n    @Override\n    public List<Label> labels() {\n        return request.get(\"labels\")\n                      .execute()\n                      .stream()\n                      .map(o -> new Label(o.get(\"name\").asString(), o.get(\"description\").asString()))\n                      .collect(Collectors.toList());\n    }\n\n    @Override\n    public void addLabel(Label label) {\n        var params = JSON.object()\n                .put(\"name\", label.name())\n                // Color is Gray and matches all current labels\n                .put(\"color\", \"ededed\");\n        if (label.description().isPresent()) {\n            params.put(\"description\", label.description().get());\n        }\n        request.post(\"labels\")\n                .body(params)\n                .execute();\n    }\n\n    @Override\n    public void updateLabel(Label label) {\n        var params = JSON.object();\n        if (label.description().isPresent()) {\n            params.put(\"description\", label.description().get());\n        } else {\n            params.put(\"description\", JSONValue.fromNull());\n        }\n        request.post(\"labels/\" + label.name())\n                .body(params)\n                .execute();\n    }\n\n    @Override\n    public void deleteLabel(Label label) {\n        request.delete(\"labels/\" + label.name())\n                .execute();\n    }\n\n    @Override\n    public int deleteDeployKeys(Duration age) {\n        var expired = request.get(\"keys\").execute()\n                .stream()\n                .filter(key -> ZonedDateTime.parse(key.get(\"created_at\").asString())\n                        .isBefore(ZonedDateTime.now().minus(age)))\n                .toList();\n        for (var key : expired) {\n            request.delete(\"keys/\" + key.get(\"id\")).execute();\n        }\n        return expired.size();\n    }\n\n    @Override\n    public boolean canCreatePullRequest(HostUser user) {\n        return true;\n    }\n\n    @Override\n    public List<PullRequest> openPullRequestsWithTargetRef(String targetRef) {\n        return request.get(\"pulls\")\n                .param(\"state\", \"open\")\n                .param(\"base\", targetRef)\n                .execute().asArray().stream()\n                .map(jsonValue -> new GitHubPullRequest(this, jsonValue, request))\n                .collect(Collectors.toList());\n    }\n\n    @Override\n    public List<String> deployKeyTitles(Duration age) {\n        var parts = name().split(\"/\");\n        var owner = parts[0];\n        var name = parts[1];\n\n        var query = String.join(\"\\n\", List.of(\n                \"{\",\n                \"  repository(owner: \\\"\" + owner + \"\\\", name: \\\"\" + name + \"\\\") {\",\n                \"    deployKeys(first: 100) {\",\n                \"      edges {\",\n                \"       node{\",\n                \"           id\",\n                \"           title\",\n                \"           createdAt\",\n                \"           }\",\n                \"      }\",\n                \"    }\",\n                \"  }\",\n                \"}\"\n        ));\n\n        var data = gitHubHost.graphQL()\n                .post()\n                .body(JSON.object().put(\"query\", query))\n                // This is a single point graphql query so shouldn't need to be limited to once a second\n                .skipLimiter(true)\n                .execute()\n                .get(\"data\");\n        return data.get(\"repository\").get(\"deployKeys\").get(\"edges\").stream()\n                .filter(key -> ZonedDateTime.parse(key.get(\"node\").get(\"createdAt\").asString())\n                        .isBefore(ZonedDateTime.now().minus(age)))\n                .map(key -> key.get(\"node\").get(\"title\").asString())\n                .toList();\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/gitlab/GitLabForgeFactory.java",
    "content": "/*\n * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.gitlab;\n\nimport java.net.URI;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.stream.Collectors;\nimport org.openjdk.skara.forge.Forge;\nimport org.openjdk.skara.forge.ForgeFactory;\nimport org.openjdk.skara.forge.internal.ForgeUtils;\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.json.JSONObject;\nimport org.openjdk.skara.json.JSONValue;\n\npublic class GitLabForgeFactory implements ForgeFactory {\n    @Override\n    public String name() {\n        return \"gitlab\";\n    }\n\n    @Override\n    public Set<String> knownHosts() {\n        return Set.of(\"gitlab.com\");\n    }\n\n    @Override\n    public Forge create(URI uri, Credential credential, JSONObject configuration) {\n        var name = \"GitLab\";\n        List<String> groups = List.of();\n        String prTemplate = null;\n        if (configuration != null) {\n            if (configuration.contains(\"name\")) {\n                name = configuration.get(\"name\").asString();\n            }\n\n            if (configuration.contains(\"groups\")) {\n                groups = configuration.get(\"groups\")\n                                    .stream()\n                                    .map(JSONValue::asString)\n                                    .toList();\n            }\n\n            if (configuration.contains(\"prTemplate\")) {\n                prTemplate = configuration.get(\"prTemplate\")\n                    .asArray()\n                    .stream()\n                    .map(JSONValue::asString)\n                    .collect(Collectors.joining(\"\\n\"));\n            }\n        }\n\n        var useSsh = false;\n        if (configuration != null && configuration.contains(\"sshkey\") && credential != null) {\n            ForgeUtils.configureSshKey(credential.username(), uri.getHost(), configuration.get(\"sshkey\").asString());\n            useSsh = true;\n        }\n\n        if (credential != null) {\n            return new GitLabHost(name, uri, useSsh, credential, groups, prTemplate);\n        } else {\n            return new GitLabHost(name, uri, useSsh, groups, prTemplate);\n        }\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/gitlab/GitLabHost.java",
    "content": "/*\n * Copyright (c) 2018, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.gitlab;\n\nimport java.time.Duration;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.network.*;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.io.IOException;\nimport java.net.*;\nimport java.nio.charset.StandardCharsets;\nimport java.util.*;\nimport java.util.logging.Logger;\n\npublic class GitLabHost implements Forge {\n    private final String name;\n    private final URI uri;\n    private final boolean useSsh;\n    private final Credential pat;\n    private final RestRequest request;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.forge.gitlab\");\n    private final List<String> groups;\n    private final String mergeRequestTemplate;\n\n    private HostUser cachedCurrentUser = null;\n\n    public GitLabHost(String name, URI uri, boolean useSsh, Credential pat, List<String> groups, String mergeRequestTemplate) {\n        this.name = name;\n        this.uri = uri;\n        this.useSsh = useSsh;\n        this.pat = pat;\n        this.groups = groups;\n        this.mergeRequestTemplate = mergeRequestTemplate;\n\n        var baseApi = URIBuilder.base(uri)\n                                .setPath(\"/api/v4/\")\n                                .build();\n        request = new RestRequest(baseApi, pat.username(), (r) -> Arrays.asList(\"Private-Token\", pat.password()));\n    }\n\n    GitLabHost(String name, URI uri, boolean useSsh, List<String> groups, String mergeRequestTemplate) {\n        this.name = name;\n        this.uri = uri;\n        this.useSsh = useSsh;\n        this.pat = null;\n        this.groups = groups;\n        this.mergeRequestTemplate = mergeRequestTemplate;\n\n        var baseApi = URIBuilder.base(uri)\n                                .setPath(\"/api/v4/\")\n                                .build();\n        request = new RestRequest(baseApi);\n    }\n\n    public URI getUri() {\n        return uri;\n    }\n\n    boolean useSsh() {\n        return useSsh;\n    }\n\n    Optional<Credential> getPat() {\n        return Optional.ofNullable(pat);\n    }\n\n    @Override\n    public String name() {\n        return name;\n    }\n\n    @Override\n    public boolean isValid() {\n        try {\n            var version = request.get(\"version\")\n                                  .executeUnparsed();\n            var parsed = JSON.parse(version);\n            if (parsed != null && parsed.contains(\"version\")) {\n                return true;\n            } else {\n                log.fine(\"Error during GitLab host validation: unexpected version: \" + version);\n                return false;\n            }\n        } catch (IOException e) {\n            log.fine(\"Error during GitLab host validation: \" + e);\n            return false;\n        }\n    }\n\n    @Override\n    public Optional<String> defaultPullRequestTemplate() {\n        return Optional.ofNullable(mergeRequestTemplate);\n    }\n\n    Optional<JSONObject> getProjectInfo(String name) {\n        var encodedName = URLEncoder.encode(name, StandardCharsets.US_ASCII);\n        var project = request.get(\"projects/\" + encodedName)\n                             .onError(r -> r.statusCode() == 404 ? Optional.of(JSON.object().put(\"retry\", true)) : Optional.empty())\n                             .execute();\n        if (project.contains(\"retry\")) {\n            // Depending on web server configuration, GitLab may need double escaping of project names\n            encodedName = URLEncoder.encode(encodedName, StandardCharsets.US_ASCII);\n            project = request.get(\"projects/\" + encodedName)\n                             .onError(r -> r.statusCode() == 404 ? Optional.of(JSON.object().put(\"NOT_FOUND\", true)) : Optional.empty())\n                             .execute();\n            if (project.contains(\"NOT_FOUND\")) {\n                return Optional.empty();\n            }\n        }\n        return Optional.of(project.asObject());\n    }\n\n    Optional<JSONObject> getProjectInfo(int id) {\n        var project = request.get(\"projects/\" + id)\n                      .onError(r -> r.statusCode() == 404 ? Optional.of(JSON.object().put(\"NOT_FOUND\", true)) : Optional.empty())\n                      .execute();\n        if (project.contains(\"NOT_FOUND\")) {\n            return Optional.empty();\n        } else {\n            return Optional.of(project.asObject());\n        }\n    }\n\n    @Override\n    public Optional<HostedRepository> repository(String name) {\n        return getProjectInfo(name)\n                .map(jsonObject -> new GitLabRepository(this, jsonObject));\n    }\n\n    HostUser parseAuthorField(JSONValue json) {\n        return parseAuthorObject(json.get(\"author\").asObject());\n    }\n\n    HostUser parseAuthorObject(JSONObject o) {\n        var id = o.get(\"id\").asInt();\n        var username = o.get(\"username\").asString();\n        var name = o.get(\"name\").asString();\n        var email = o.get(\"email\") != null ? o.get(\"email\").asString() : \"\";\n        return HostUser.builder()\n                       .id(id)\n                       .username(username)\n                       .fullName(name)\n                       .email(email)\n                       .build();\n    }\n\n    @Override\n    public Optional<HostUser> user(String username) {\n        var details = request.get(\"users\")\n                             .param(\"username\", username)\n                             .onError(r -> r.statusCode() == 404 ? Optional.of(JSON.of()) : Optional.empty())\n                             .execute();\n\n        if (details.isNull()) {\n            return Optional.empty();\n        }\n\n        var users = details.asArray();\n        if (users.size() != 1) {\n            return Optional.empty();\n        }\n\n        return Optional.of(parseAuthorObject(users.get(0).asObject()));\n    }\n\n    @Override\n    public Optional<HostUser> userById(String id) {\n        var details = request.get(\"users/\" + id)\n                .onError(r -> r.statusCode() == 404 ? Optional.of(JSON.of()) : Optional.empty())\n                .execute();\n\n        if (details.isNull()) {\n            return Optional.empty();\n        }\n\n        return Optional.of(parseAuthorObject(details.asObject()));\n    }\n\n\n    @Override\n    public HostUser currentUser() {\n        if (cachedCurrentUser != null) {\n            return cachedCurrentUser;\n        }\n        var details = request.get(\"user\").execute().asObject();\n        cachedCurrentUser = parseAuthorObject(details);\n        return cachedCurrentUser;\n    }\n\n    boolean isProjectForkComplete(String name) {\n        var project = getProjectInfo(name);\n        if (project.isPresent() && project.get().contains(\"import_status\")) {\n            var status = project.get().get(\"import_status\").asString();\n            switch (status) {\n                case \"finished\":\n                    return true;\n                case \"started\":\n                    return false;\n                default:\n                    throw new RuntimeException(\"Unknown fork status: \" + status);\n            }\n        } else {\n            throw new RuntimeException(\"Project does not seem to be a fork\");\n        }\n    }\n\n    @Override\n    public boolean isMemberOf(String groupId, HostUser user) {\n        long gid = 0L;\n        try {\n            gid = Long.parseLong(groupId);\n        } catch (NumberFormatException e) {\n            throw new IllegalArgumentException(\"Group id is not a number: \" + groupId);\n        }\n        var details = request.get(\"groups/\" + gid + \"/members/\" + user.id())\n                             .onError(r -> Optional.of(JSON.of()))\n                             .execute();\n        return !details.isNull();\n    }\n\n    @Override\n    public Optional<String> search(Hash hash, boolean includeDiffs) {\n        for (var group : groups) {\n            var ids = request.get(\"groups/\" + group + \"/projects\")\n                                  .execute()\n                                  .stream()\n                                  // When searching for a commit, there may be hits in multiple repositories.\n                                  // There is no good way of knowing for sure which repository we would rather\n                                  // get the commit from, but a reasonable default is to go by the shortest\n                                  // path name as that is most likely the main repository of the project.\n                                  .sorted(Comparator.comparing(o -> o.get(\"path\").asString().length()))\n                                  .map(o -> o.get(\"id\").asInt())\n                                  .toList();\n            for (var id : ids) {\n                var project = new GitLabRepository(this, id);\n                var commit = project.commit(hash, includeDiffs);\n                if (commit.isPresent()) {\n                    return Optional.of(project.name());\n                }\n            }\n        }\n        return Optional.empty();\n    }\n\n    @Override\n    public String hostname() {\n        return uri.getHost();\n    }\n\n    URI getWebUri(String endpoint) {\n        return URI.create(uri.toString() + endpoint);\n    }\n\n    @Override\n    public Duration minTimeStampUpdateInterval() {\n        return Duration.ofMinutes(1);\n    }\n\n    @Override\n    public Duration timeStampQueryPrecision() {\n        return Duration.ofSeconds(1);\n    }\n\n    @Override\n    public List<HostUser> groupMembers(String group) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void addGroupMember(String group, HostUser user) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public MemberState groupMemberState(String group, HostUser user) {\n        throw new UnsupportedOperationException();\n    }\n\n    List<String> groups() {\n        return List.copyOf(groups);\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/gitlab/GitLabMergeRequest.java",
    "content": "/*\n * Copyright (c) 2018, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.gitlab;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.network.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.net.URI;\nimport java.nio.charset.StandardCharsets;\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\nimport java.util.stream.*;\n\npublic class GitLabMergeRequest implements PullRequest {\n    private final JSONValue json;\n    private final RestRequest request;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.host\");;\n    private final GitLabRepository repository;\n    private final GitLabHost host;\n\n    // Only cache the label names as those are most commonly used and converting to\n    // Label objects is expensive. This list is always sorted.\n    private List<String> labels;\n\n    // Lazy cache for comparisonSnapshot\n    private Object comparisonSnapshot;\n\n    private static final int GITLAB_MR_COMMENT_BODY_MAX_SIZE = 64_000;\n    private static final String DRAFT_PREFIX = \"Draft:\";\n\n    GitLabMergeRequest(GitLabRepository repository, GitLabHost host, JSONValue jsonValue, RestRequest request) {\n        this.repository = repository;\n        this.host = host;\n        this.json = jsonValue;\n        this.request = request.restrict(\"merge_requests/\" + json.get(\"iid\").toString() + \"/\");\n\n        labels = json.get(\"labels\").stream()\n                     .map(JSONValue::asString)\n                     .sorted()\n                     .collect(Collectors.toList());\n    }\n\n    @Override\n    public HostedRepository repository() {\n        return repository;\n    }\n\n    @Override\n    public IssueProject project() {\n        return null;\n    }\n\n    @Override\n    public String id() {\n        return json.get(\"iid\").toString();\n    }\n\n    @Override\n    public HostUser author() {\n        return host.parseAuthorField(json);\n    }\n\n    @Override\n    public List<Review> reviews() {\n\n        class CommitDate {\n            private Hash hash;\n            private ZonedDateTime date;\n        }\n\n        var commits = request.get(\"versions\").execute().stream()\n                             .map(JSONValue::asObject)\n                             .map(obj -> {\n                                 var ret = new CommitDate();\n                                 ret.hash = new Hash(obj.get(\"head_commit_sha\").asString());\n                                 ret.date = ZonedDateTime.parse(obj.get(\"created_at\").asString());\n                                 return ret;\n                             })\n                             .collect(Collectors.toCollection(ArrayList::new));\n        // Commits are returned in reverse chronological order. We want them\n        // primarily in chronological order based on the \"created_at\" date\n        // and secondary in the reverse order they originally came in. We can\n        // trust that List::sort is stable.\n        Collections.reverse(commits);\n        commits.sort(Comparator.comparing(cd -> cd.date));\n\n        // It's possible to create a merge request without any commits\n        if (commits.size() == 0) {\n            return List.of();\n        }\n\n        var currentTargetRef = targetRef();\n        var notes = request.get(\"notes\").execute();\n        var reviews = notes.stream()\n                               .map(JSONValue::asObject)\n                               .filter(obj -> obj.get(\"system\").asBoolean())\n                               // This matches both approved and unapproved notes\n                               .filter(obj -> obj.get(\"body\").asString().contains(\"approved this merge request\"))\n                               .map(obj -> {\n                                   var reviewerObj = obj.get(\"author\").asObject();\n                                   var reviewer = HostUser.create(reviewerObj.get(\"id\").asInt(),\n                                                                  reviewerObj.get(\"username\").asString(),\n                                                                  reviewerObj.get(\"name\").asString());\n                                   var verdict = obj.get(\"body\").asString().contains(\"unapproved\") ? Review.Verdict.NONE : Review.Verdict.APPROVED;\n                                   var createdAt = ZonedDateTime.parse(obj.get(\"created_at\").asString());\n\n                                   // Find the latest commit that isn't created after our review\n                                   Hash hash = null;\n                                   for (var cd : commits) {\n                                       if (createdAt.isAfter(cd.date)) {\n                                           hash = cd.hash;\n                                       }\n                                   }\n                                   var id = obj.get(\"id\").toString();\n                                   return new Review(createdAt, reviewer, verdict, hash, id, \"\", currentTargetRef);\n                               }).toList();\n        var targetRefChanges = targetRefChanges(notes);\n        return PullRequest.calculateReviewTargetRefs(reviews, targetRefChanges);\n    }\n\n    private static final Pattern REF_CHANGES_PATTERN = Pattern.compile(\"changed target branch from `(.*)` to `(.*)`\");\n    private List<ReferenceChange> targetRefChanges(JSONValue notes) {\n        return notes.stream()\n                .map(JSONValue::asObject)\n                .filter(obj -> obj.get(\"system\").asBoolean())\n                .map(obj -> {\n                    var matcher = REF_CHANGES_PATTERN.matcher(obj.get(\"body\").asString());\n                    if (matcher.matches()) {\n                        return new ReferenceChange(matcher.group(1), matcher.group(2), ZonedDateTime.parse(obj.get(\"created_at\").asString()));\n                    } else {\n                        return null;\n                    }\n                })\n                .filter(Objects::nonNull)\n                .toList();\n    }\n\n    @Override\n    public List<ReferenceChange> targetRefChanges() {\n        return targetRefChanges(request.get(\"notes\").execute());\n    }\n\n    @Override\n    public void addReview(Review.Verdict verdict, String body) {\n        // Remove any previous awards\n        var awards = request.get(\"award_emoji\").execute().stream()\n                            .map(JSONValue::asObject)\n                            .filter(obj -> obj.get(\"name\").asString().equals(\"thumbsup\") ||\n                                    obj.get(\"name\").asString().equals(\"thumbsdown\") ||\n                                    obj.get(\"name\").asString().equals(\"question\"))\n                            .filter(obj -> obj.get(\"user\").get(\"username\").asString().equals(repository.forge().currentUser().username()))\n                            .map(obj -> obj.get(\"id\").toString())\n                            .collect(Collectors.toList());\n        for (var award : awards) {\n            request.delete(\"award_emoji/\" + award).execute();\n        }\n\n        String award;\n        switch (verdict) {\n            case APPROVED:\n                award = \"thumbsup\";\n                break;\n            case DISAPPROVED:\n                award = \"thumbsdown\";\n                break;\n            default:\n                award = \"question\";\n                break;\n        }\n        request.post(\"award_emoji\")\n               .body(\"name\", award)\n               .execute();\n    }\n\n    @Override\n    public void updateReview(String id, String body) {\n        throw new RuntimeException(\"not implemented yet\");\n    }\n\n    private ReviewComment parseReviewComment(String discussionId, ReviewComment parent, JSONObject note) {\n        int line;\n        String path;\n        Hash hash;\n\n        var position = note.get(\"position\");\n        // Is this a line comment?\n        // For line comments, this field is always set, either to a value or null, but\n        // for file comments there is no new_line field at all.\n        if (position.get(\"new_line\") != null) {\n            // Is the comment on the old or the new version of the file?\n            if (position.get(\"new_line\").isNull()) {\n                line = position.get(\"old_line\").asInt();\n                path = position.get(\"old_path\").asString();\n                hash = new Hash(position.get(\"start_sha\").asString());\n            } else {\n                line = position.get(\"new_line\").asInt();\n                path = position.get(\"new_path\").asString();\n                hash = new Hash(position.get(\"head_sha\").asString());\n            }\n        } else {\n            // This comment does not have a line. Gitlab seems to only allow file comments\n            // on the new file\n            line = 0;\n            path = position.get(\"new_path\").asString();\n            hash = new Hash(position.get(\"head_sha\").asString());\n        }\n\n        var comment = new ReviewComment(parent,\n                                        discussionId,\n                                        hash,\n                                        path,\n                                        line,\n                                        note.get(\"id\").toString(),\n                                        note.get(\"body\").asString(),\n                                        HostUser.create(note.get(\"author\").get(\"id\").asInt(),\n                                                        note.get(\"author\").get(\"username\").asString(),\n                                                        note.get(\"author\").get(\"name\").asString()),\n                                        ZonedDateTime.parse(note.get(\"created_at\").asString()),\n                                        ZonedDateTime.parse(note.get(\"updated_at\").asString()));\n        return comment;\n    }\n\n    @Override\n    public ReviewComment addReviewComment(Hash base, Hash hash, String path, int line, String body) {\n        log.fine(\"Posting a new review comment\");\n        var query = JSON.object()\n                        .put(\"body\", body)\n                        .put(\"position\", JSON.object()\n                                             .put(\"base_sha\", base.hex())\n                                             .put(\"start_sha\", base.hex())\n                                             .put(\"head_sha\", hash.hex())\n                                             .put(\"position_type\", \"text\")\n                                             .put(\"new_path\", path)\n                                             .put(\"new_line\", line));\n        var comments = request.post(\"discussions\").body(query).execute();\n        if (comments.get(\"notes\").asArray().size() != 1) {\n            throw new RuntimeException(\"Failed to create review comment\");\n        }\n        var parsedComment = parseReviewComment(comments.get(\"id\").asString(), null,\n                                               comments.get(\"notes\").asArray().get(0).asObject());\n        log.fine(\"Id of new review comment: \" + parsedComment.id());\n        return parsedComment;\n    }\n\n    @Override\n    public ReviewComment addReviewCommentReply(ReviewComment parent, String body) {\n        var discussionId = parent.threadId();\n        var comment = request.post(\"discussions/\" + discussionId + \"/notes\")\n                             .body(\"body\", body)\n                             .execute();\n        return parseReviewComment(discussionId, parent, comment.asObject());\n    }\n\n    private List<ReviewComment> parseDiscussion(JSONObject discussion) {\n        var ret = new ArrayList<ReviewComment>();\n        ReviewComment parent = null;\n        for (var note : discussion.get(\"notes\").asArray()) {\n            // Ignore system generated comments\n            if (note.get(\"system\").asBoolean()) {\n                continue;\n            }\n            // Ignore plain comments\n            if (!note.contains(\"position\")) {\n                continue;\n            }\n\n            var comment = parseReviewComment(discussion.get(\"id\").asString(), parent, note.asObject());\n            parent = comment;\n            ret.add(comment);\n        }\n\n        return ret;\n    }\n\n    @Override\n    public List<ReviewComment> reviewComments() {\n        return request.get(\"discussions\").execute().stream()\n                      .filter(entry -> !entry.get(\"individual_note\").asBoolean())\n                      .flatMap(entry -> parseDiscussion(entry.asObject()).stream())\n                      .collect(Collectors.toList());\n    }\n\n    @Override\n    public Hash headHash() {\n        return new Hash(json.get(\"sha\").asString());\n    }\n\n    @Override\n    public String fetchRef() {\n        return \"merge-requests/\" + id() + \"/head\";\n    }\n\n    @Override\n    public String sourceRef() {\n        return json.get(\"source_branch\").asString();\n    }\n\n    @Override\n    public Optional<HostedRepository> sourceRepository() {\n        if (json.get(\"source_project_id\").isNull()) {\n            return Optional.empty();\n        } else {\n            var projectId = json.get(\"source_project_id\").asInt();\n            var project = ((GitLabHost) repository.forge()).getProjectInfo(projectId);\n            if (project.isEmpty()) {\n                return Optional.empty();\n            } else {\n                return Optional.of(new GitLabRepository((GitLabHost) repository.forge(), project.get()));\n            }\n        }\n    }\n\n    @Override\n    public String targetRef() {\n        var targetRef = json.get(\"target_branch\").asString();\n        return targetRef;\n    }\n\n    /**\n     * In GitLab, if the pull request is in draft mode, the title will include the draft prefix\n     */\n    @Override\n    public String title() {\n        var title = json.get(\"title\").asString().strip();\n        String pattern = \"(?i)^draft:?\\\\s*\";\n        return title.replaceAll(pattern, \"\").strip();\n    }\n\n    /**\n     * In GitLab, when the bot attempts to update the pull request title,\n     * it should check if the pull request is in draft mode.\n     * If it is, the bot should add the draft prefix.\n     */\n    @Override\n    public void setTitle(String title) {\n        if (isDraft()) {\n            title = DRAFT_PREFIX + \" \" + title;\n        }\n        request.put(\"\")\n               .body(\"title\", title)\n               .execute();\n    }\n\n    /**\n     * This method sets the title without checking if the pull request is in draft mode.\n     */\n    private void setTitleWithoutDraftPrefix(String title) {\n        request.put(\"\")\n               .body(\"title\", title)\n               .execute();\n    }\n\n    @Override\n    public String body() {\n        var body = json.get(\"description\").asString();\n        if (body == null) {\n            body = \"\";\n        }\n        return body;\n    }\n\n    @Override\n    public void setBody(String body) {\n        request.put(\"\")\n               .body(\"description\", body)\n               .execute();\n    }\n\n    private Comment parseComment(JSONValue comment) {\n        var ret = new Comment(comment.get(\"id\").toString(),\n                              comment.get(\"body\").asString(),\n                              HostUser.create(comment.get(\"author\").get(\"id\").asInt(),\n                                              comment.get(\"author\").get(\"username\").asString(),\n                                              comment.get(\"author\").get(\"name\").asString()),\n                              ZonedDateTime.parse(comment.get(\"created_at\").asString()),\n                              ZonedDateTime.parse(comment.get(\"updated_at\").asString()));\n        return ret;\n    }\n\n    @Override\n    public List<Comment> comments() {\n        return request.get(\"notes\").param(\"sort\", \"asc\").execute().stream()\n                      .filter(entry -> !entry.contains(\"position\")) // Ignore comments with a position - they are review comments\n                      .filter(entry -> !entry.get(\"system\").asBoolean()) // Ignore system generated comments\n                .map(this::parseComment)\n                .collect(Collectors.toList());\n    }\n\n    @Override\n    public Comment addComment(String body) {\n        log.fine(\"Posting a new comment\");\n        body = limitBodySize(body);\n        var comment = request.post(\"notes\")\n                             .body(\"body\", body)\n                             .execute();\n        var parsedComment = parseComment(comment);\n        log.fine(\"Id of new comment: \" + parsedComment.id());\n        return parsedComment;\n    }\n\n    @Override\n    public void removeComment(Comment comment) {\n        throw new RuntimeException(\"not implemented yet\");\n    }\n\n    @Override\n    public Comment updateComment(String id, String body) {\n        log.fine(\"Updating existing comment \" + id);\n        body =  limitBodySize(body);\n        var comment = request.put(\"notes/\" + id)\n                             .body(\"body\", body)\n                             .execute();\n        var parsedComment = parseComment(comment);\n        log.fine(\"Id of updated comment: \" + parsedComment.id());\n        return parsedComment;\n    }\n\n    @Override\n    public ZonedDateTime createdAt() {\n        return ZonedDateTime.parse(json.get(\"created_at\").asString());\n    }\n\n    @Override\n    public ZonedDateTime updatedAt() {\n        return ZonedDateTime.parse(json.get(\"updated_at\").asString());\n    }\n\n    @Override\n    public State state() {\n        if (json.get(\"state\").asString().equals(\"opened\")) {\n            return State.OPEN;\n        }\n        return State.CLOSED;\n    }\n\n    private final String checkMarker = \"<!-- Merge request status check message (%s) -->\";\n    private final String checkResultMarker = \"<!-- Merge request status check result (%s) (%s) (%s) (%s) -->\";\n    private final String checkResultPattern = \"<!-- Merge request status check result \\\\(([-\\\\w]+)\\\\) \\\\((\\\\w+)\\\\) \\\\(%s\\\\) \\\\((\\\\S+)\\\\) -->\";\n\n    private Optional<Comment> getStatusCheckComment(String name) {\n        var marker = String.format(checkMarker, name);\n\n        return comments().stream()\n                         .filter(c -> c.body().contains(marker))\n                         .findFirst();\n    }\n\n    private String encodeMarkdown(String message) {\n        return message.replaceAll(\"\\n\", \"  \\n\");\n    }\n\n    private final Pattern checkBodyPattern = Pattern.compile(\"^# ([^\\\\n\\\\r]*)\\\\R(.*)\",\n                                                             Pattern.DOTALL | Pattern.MULTILINE);\n\n    @Override\n    public Map<String, Check> checks(Hash hash) {\n        var pattern = Pattern.compile(String.format(checkResultPattern, hash.hex()));\n        var matchers = comments().stream()\n                                 .collect(Collectors.toMap(comment -> comment,\n                        comment -> pattern.matcher(comment.body())));\n\n        return matchers.entrySet().stream()\n                .filter(entry -> entry.getValue().find())\n                .collect(Collectors.toMap(entry -> entry.getValue().group(1),\n                        entry -> {\n                            var checkBuilder = CheckBuilder.create(entry.getValue().group(1), hash);\n                            checkBuilder.startedAt(entry.getKey().createdAt());\n                            var status = entry.getValue().group(2);\n                            var completedAt = entry.getKey().updatedAt();\n                            switch (status) {\n                                case \"RUNNING\":\n                                    // do nothing\n                                    break;\n                                case \"SUCCESS\":\n                                    checkBuilder.complete(true, completedAt);\n                                    break;\n                                case \"FAILURE\":\n                                    checkBuilder.complete(false, completedAt);\n                                    break;\n                                case \"CANCELLED\":\n                                    checkBuilder.cancel(completedAt);\n                                    break;\n                                default:\n                                    throw new IllegalStateException(\"Unknown status: \" + status);\n                            }\n                            if (!entry.getValue().group(3).equals(\"NONE\")) {\n                                checkBuilder.metadata(new String(Base64.getDecoder().decode(entry.getValue().group(3)), StandardCharsets.UTF_8));\n                            }\n                            var checkBodyMatcher = checkBodyPattern.matcher(entry.getKey().body());\n                            if (checkBodyMatcher.find()) {\n                                // escapeMarkdown adds an additional space before the newline\n                                var title = checkBodyMatcher.group(1);\n                                var nonEscapedTitle = title.substring(0, title.length() - 2);\n                                checkBuilder.title(nonEscapedTitle);\n                                checkBuilder.summary(checkBodyMatcher.group(2));\n                            }\n                            return checkBuilder.build();\n                        }));\n    }\n\n    private String statusFor(Check check) {\n        switch (check.status()) {\n            case IN_PROGRESS:\n                return \"RUNNING\";\n            case SUCCESS:\n                return \"SUCCESS\";\n            case FAILURE:\n                return \"FAILURE\";\n            case CANCELLED:\n                return \"CANCELLED\";\n            default:\n                throw new RuntimeException(\"Unknown check status\");\n        }\n    }\n\n    private String metadataFor(Check check) {\n        if (check.metadata().isPresent()) {\n            return Base64.getEncoder().encodeToString(check.metadata().get().getBytes(StandardCharsets.UTF_8));\n        }\n        return \"NONE\";\n    }\n\n    private String linkToDiff(String path, Hash hash, int line) {\n        return \"[\" + path + \" line \" + line + \"](\" + URIBuilder.base(repository.url())\n                         .setPath(\"/\" + repository.name()+ \"/blob/\" + hash.hex() + \"/\" + path)\n                         .setAuthentication(null)\n                         .build() + \"#L\" + Integer.toString(line) + \")\";\n    }\n\n    private String bodyFor(Check check) {\n        var status = check.status();\n        String body;\n        switch (status) {\n            case IN_PROGRESS:\n                body = \":hourglass_flowing_sand: The merge request check **\" + check.name() + \"** is currently running...\";\n                break;\n            case SUCCESS:\n                body = \":tada: The merge request check **\" + check.name() + \"** completed successfully!\";\n                break;\n            case FAILURE:\n                body = \":warning: The merge request check **\" + check.name() + \"** identified the following issues:\";\n                break;\n            case CANCELLED:\n                body = \":x: The merge request check **\" + check.name() + \"** has been cancelled.\";\n                break;\n            default:\n                throw new RuntimeException(\"Unknown check status\");\n        }\n\n        if (check.title().isPresent()) {\n            body += encodeMarkdown(\"\\n\" + \"# \" + check.title().get());\n        }\n\n        if (check.summary().isPresent()) {\n            body += encodeMarkdown(\"\\n\" + check.summary().get());\n        }\n\n        for (var annotation : check.annotations()) {\n            var annotationString = \"  - \";\n            switch (annotation.level()) {\n                case NOTICE:\n                    annotationString += \"Notice: \";\n                    break;\n                case WARNING:\n                    annotationString += \"Warning: \";\n                    break;\n                case FAILURE:\n                    annotationString += \"Failure: \";\n                    break;\n            }\n            annotationString += linkToDiff(annotation.path(), check.hash(), annotation.startLine());\n            annotationString += \"\\n    - \" + annotation.message().lines().collect(Collectors.joining(\"\\n    - \"));\n\n            body += \"\\n\" + annotationString;\n        }\n\n        return body;\n    }\n\n    private void updateCheckComment(Optional<Comment> previous, Check check) {\n        var status = statusFor(check);\n        var metadata = metadataFor(check);\n        var markers = String.format(checkMarker, check.name()) + \"\\n\" +\n                      String.format(checkResultMarker,\n                                    check.name(),\n                                    status,\n                                    check.hash(),\n                                    metadata);\n\n        var body = bodyFor(check);\n        var message = markers + \"\\n\" + body;\n        previous.ifPresentOrElse(\n                p  -> updateComment(p.id(), message),\n                () -> addComment(message));\n    }\n\n    @Override\n    public void createCheck(Check check) {\n        log.info(\"Looking for previous status check comment\");\n\n        var previous = getStatusCheckComment(check.name());\n        updateCheckComment(previous, check);\n    }\n\n    @Override\n    public void updateCheck(Check check) {\n        log.info(\"Looking for previous status check comment\");\n\n        var previous = getStatusCheckComment(check.name())\n                .orElseGet(() -> addComment(\"Progress deleted?\"));\n        updateCheckComment(Optional.of(previous), check);\n    }\n\n    @Override\n    public URI changeUrl() {\n        return URIBuilder.base(webUrl()).appendPath(\"/diffs\").build();\n    }\n\n    @Override\n    public URI changeUrl(Hash base) {\n        return URIBuilder.base(webUrl()).appendPath(\"/diffs\")\n                         .setQuery(Map.of(\"start_sha\", List.of(base.hex())))\n                         .build();\n    }\n\n    @Override\n    public URI commentUrl(Comment comment) {\n        return URIBuilder.base(webUrl()).appendPath(\"#note_\" + comment.id()).build();\n    }\n\n    @Override\n    public URI reviewCommentUrl(ReviewComment reviewComment) {\n        return URIBuilder.base(webUrl()).appendPath(\"#note_\" + reviewComment.id()).build();\n    }\n\n    @Override\n    public URI reviewUrl(Review review) {\n        return URIBuilder.base(webUrl()).appendPath(\"#note_\" + review.id()).build();\n    }\n\n    @Override\n    public boolean isDraft() {\n        return json.get(\"draft\").asBoolean();\n    }\n\n\n    @Override\n    public void setState(State state) {\n        request.put(\"\")\n               .body(\"state_event\", state != State.OPEN ? \"close\" : \"reopen\")\n               .execute();\n    }\n\n    private Map<String, Label> labelNameToLabel;\n\n    /**\n     * Lookup a label from the repository labels. Initialize and refresh a cache\n     * of the repository labels lazily.\n     */\n    private Label labelNameToLabel(String labelName) {\n        if (labelNameToLabel == null || !labelNameToLabel.containsKey(labelName)) {\n            labelNameToLabel = repository.labels()\n                    .stream()\n                    .collect(Collectors.toMap(Label::name, l -> l));\n        }\n        return labelNameToLabel.get(labelName);\n    }\n\n    @Override\n    public void addLabel(String label) {\n        labels = null;\n        request.put(\"\")\n                .body(\"add_labels\", label)\n                .execute();\n    }\n\n    @Override\n    public void removeLabel(String label) {\n        labels = null;\n        request.put(\"\")\n                .body(\"remove_labels\", label)\n                .execute();\n    }\n\n    @Override\n    public void setLabels(List<String> labels) {\n        request.put(\"\")\n               .body(\"labels\", String.join(\",\", labels))\n               .execute();\n        this.labels = labels.stream().sorted().toList();\n    }\n\n    @Override\n    public List<Label> labels() {\n        return labelNames().stream()\n                .map(this::labelNameToLabel)\n                // Avoid throwing NPE for unknown labels\n                .filter(Objects::nonNull)\n                .toList();\n    }\n\n    @Override\n    public List<String> labelNames() {\n        if (labels == null) {\n            labels = request.get(\"\").execute().get(\"labels\").stream()\n                    .map(JSONValue::asString)\n                    .sorted()\n                    .collect(Collectors.toList());\n        }\n        return labels;\n    }\n\n    @Override\n    public URI webUrl() {\n        return URIBuilder.base(repository.webUrl())\n                         .setPath(\"/\" + repository.name() + \"/merge_requests/\" + id())\n                         .build();\n    }\n\n    @Override\n    public String toString() {\n        return \"GitLabMergeRequest #\" + id() + \" by \" + author();\n    }\n\n    @Override\n    public List<HostUser> assignees() {\n        var assignee = json.get(\"assignee\").asObject();\n        if (assignee != null) {\n            var user = repository.forge().user(assignee.get(\"username\").asString());\n            return List.of(user.get());\n        }\n        return Collections.emptyList();\n    }\n\n    @Override\n    public void setAssignees(List<HostUser> assignees) {\n        var id = assignees.size() == 0 ? 0 : Integer.valueOf(assignees.get(0).id());\n        var param = JSON.object().put(\"assignee_id\", id);\n        request.put().body(param).execute();\n        if (assignees.size() > 1) {\n            var rest = assignees.subList(1, assignees.size());\n            var usernames = rest.stream()\n                                .map(HostUser::username)\n                                .map(username -> \"@\" + username)\n                                .collect(Collectors.joining(\" \"));\n            var comment = usernames + \" can you have a look at this merge request?\";\n            addComment(comment);\n        }\n    }\n\n    @Override\n    public void makeNotDraft() {\n        if (isDraft()) {\n            setTitleWithoutDraftPrefix(title());\n        }\n    }\n\n    @Override\n    public Optional<ZonedDateTime> lastMarkedAsDraftTime() {\n        var draftMessage = \"marked this merge request as **draft**\";\n        var notes = request.get(\"notes\").execute();\n        var lastMarkedAsDraftTime = notes.stream()\n                .map(JSONValue::asObject)\n                .filter(obj -> obj.get(\"system\").asBoolean())\n                .filter(obj -> draftMessage.equals(obj.get(\"body\").asString()))\n                .map(obj -> ZonedDateTime.parse(obj.get(\"created_at\").asString()))\n                .max(ZonedDateTime::compareTo);\n        if (lastMarkedAsDraftTime.isEmpty() && isDraft()) {\n            return Optional.of(createdAt());\n        }\n        return lastMarkedAsDraftTime;\n    }\n\n    @Override\n    public URI diffUrl() {\n        return URI.create(webUrl() + \".diff\");\n    }\n\n    @Override\n    public Optional<ZonedDateTime> labelAddedAt(String label) {\n        return request.get(\"resource_label_events\")\n                      .execute()\n                      .stream()\n                      .map(JSONValue::asObject)\n                      .filter(obj -> obj.contains(\"action\"))\n                      .filter(obj -> obj.get(\"action\").asString().equals(\"add\"))\n                      .filter(obj -> obj.get(\"label\").get(\"name\").asString().equals(label))\n                      .map(o -> ZonedDateTime.parse(o.get(\"created_at\").asString()))\n                      .findFirst();\n    }\n\n    @Override\n    public void setTargetRef(String targetRef) {\n        request.put(\"\")\n               .body(\"target_branch\", targetRef)\n               .execute();\n    }\n\n    @Override\n    public URI headUrl() {\n        return URI.create(webUrl() + \"/diffs?commit_id=\" + headHash().hex());\n    }\n\n    @Override\n    public Diff diff() {\n        var changes = request.get(\"changes\").param(\"access_raw_diffs\", \"true\").execute();\n        boolean complete;\n        if (changes.get(\"overflow\").asBoolean()) {\n            complete = false;\n        } else {\n            complete = !changes.get(\"changes_count\").asString().contains(\"+\");\n        }\n        var targetHash = repository.branchHash(targetRef()).orElseThrow();\n        return repository.toDiff(targetHash, headHash(), changes.get(\"changes\"), complete);\n    }\n\n    @Override\n    public Optional<HostUser> closedBy() {\n        if (!isClosed()) {\n            return Optional.empty();\n        }\n        JSONValue closedBy = json.get(\"closed_by\");\n        // When MR is in what Skara considers \"closed\", it may also have been\n        // integrated directly in Gitlab. If so, the closed_by field will be\n        // null, and the merged_by field will be populated instead.\n        if (closedBy.isNull()) {\n            closedBy = json.get(\"merged_by\");\n        }\n        if (closedBy.isNull()) {\n            return Optional.empty();\n        }\n        return Optional.of(host.parseAuthorObject(closedBy.asObject()));\n    }\n\n    @Override\n    public URI filesUrl(Hash hash) {\n        var versionId = request.get(\"versions\").execute().stream()\n                               .filter(version -> hash.hex().equals(version.get(\"head_commit_sha\").asString()))\n                               .map(version -> String.valueOf(version.get(\"id\").asInt()))\n                               .findFirst();\n        String uri;\n        if (versionId.isEmpty()) {\n            uri = \"/\" + repository.name() + \"/-/merge_requests/\" + id() + \"/diffs?commit_id=\" + hash.hex();\n        } else {\n            uri = \"/\" + repository.name() + \"/-/merge_requests/\" + id() + \"/diffs?diff_id=\" + versionId.get();\n        }\n        return host.getWebUri(uri);\n    }\n\n    @Override\n    public Optional<ZonedDateTime> lastForcePushTime() {\n        return Optional.empty();\n    }\n\n    @Override\n    public Optional<Hash> findIntegratedCommitHash() {\n        return findIntegratedCommitHash(List.of(repository.forge().currentUser().id()));\n    }\n\n    /**\n     * For GitLabMergeRequest, a snapshot comparison needs to include the comments\n     * and reviews, which are both part of the general \"notes\".\n     */\n    @Override\n    public Object snapshot() {\n        if (comparisonSnapshot == null) {\n            comparisonSnapshot = List.of(json, request.get(\"notes\").execute());\n        }\n        return comparisonSnapshot;\n    }\n\n    /**\n     * Equality for a GitLabMergeRequest is based on the data snapshot retrieved\n     * when the instance was created.\n     */\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        GitLabMergeRequest that = (GitLabMergeRequest) o;\n        return json.equals(that.json);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(json);\n    }\n\n    private String limitBodySize(String body) {\n        if (body.length() > GITLAB_MR_COMMENT_BODY_MAX_SIZE) {\n            return body.substring(0, GITLAB_MR_COMMENT_BODY_MAX_SIZE)\n                    + \"...\";\n        }\n        return body;\n    }\n\n    @Override\n    public ZonedDateTime lastTouchedTime() {\n        Set<String> relevantEvents = Set.of(\n                \"marked this merge request as **ready**\",\n                \"marked this merge request as **draft**\"\n        );\n        // Get relevant note timestamps\n        Stream<ZonedDateTime> noteTimes = request.get(\"notes\")\n                .execute().stream()\n                .map(JSONValue::asObject)\n                .filter(note -> relevantEvents.contains(note.get(\"body\").asString()))\n                .map(note -> ZonedDateTime.parse(note.get(\"created_at\").asString()));\n\n        // Get commit dates\n        Stream<ZonedDateTime> commitTimes = request.get(\"commits\")\n                .execute().stream()\n                .map(JSONValue::asObject)\n                .map(commit -> ZonedDateTime.parse(commit.get(\"committed_date\").asString()));\n\n        // Combine and get latest time\n        return Stream.concat(noteTimes, commitTimes)\n                .max(ZonedDateTime::compareTo)\n                .orElseGet(this::createdAt);\n    }\n\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/gitlab/GitLabRepository.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.gitlab;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.util.logging.Logger;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Label;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.network.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.net.*;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Path;\nimport java.time.*;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class GitLabRepository implements HostedRepository {\n    private static final Logger log = Logger.getLogger(GitLabRepository.class.getName());\n    private final GitLabHost gitLabHost;\n    private final String projectName;\n    private final RestRequest request;\n    private final JSONValue json;\n    private final Pattern mergeRequestPattern;\n    private final ZonedDateTime instantiated;\n\n    private static final ZonedDateTime EPOCH = ZonedDateTime.ofInstant(Instant.EPOCH, ZoneOffset.UTC);\n    private static final ConcurrentHashMap<String, ConcurrentHashMap<String, ConcurrentHashMap<Hash, Boolean>>> projectsToTitleToHashes = new ConcurrentHashMap<>();\n    private static final ConcurrentHashMap<String, ZonedDateTime> lastCommitUpdates = new ConcurrentHashMap<>();\n\n    public GitLabRepository(GitLabHost gitLabHost, String projectName) {\n        this(gitLabHost, gitLabHost.getProjectInfo(projectName).orElseThrow(() -> new RuntimeException(\"Project not found: \" + projectName)));\n    }\n\n    public GitLabRepository(GitLabHost gitLabHost, int id) {\n        this(gitLabHost, gitLabHost.getProjectInfo(id).orElseThrow(() -> new RuntimeException(\"Project not found by id: \" + id)));\n    }\n\n    GitLabRepository(GitLabHost gitLabHost, JSONValue json) {\n        this.gitLabHost = gitLabHost;\n        this.json = json;\n        this.projectName = json.get(\"path_with_namespace\").asString();\n\n        var id = json.get(\"id\").asInt();\n        var baseApi = URIBuilder.base(gitLabHost.getUri())\n                .setPath(\"/api/v4/projects/\" + id + \"/\")\n                .build();\n\n        request = gitLabHost.getPat()\n                            .map(pat -> new RestRequest(baseApi, pat.username(), (r) -> Arrays.asList(\"Private-Token\", pat.password())))\n                            .orElseGet(() -> new RestRequest(baseApi));\n\n        var urlPattern = URIBuilder.base(gitLabHost.getUri())\n                                   .setPath(\"/\" + projectName + \"/merge_requests/\").build();\n        mergeRequestPattern = Pattern.compile(urlPattern.toString() + \"(\\\\d+)\");\n        instantiated = ZonedDateTime.now();\n\n        projectsToTitleToHashes.putIfAbsent(projectName, new ConcurrentHashMap<>());\n        lastCommitUpdates.putIfAbsent(projectName, EPOCH);\n    }\n\n    @Override\n    public Forge forge() {\n        return gitLabHost;\n    }\n\n    @Override\n    public Optional<HostedRepository> parent() {\n        if (json.contains(\"forked_from_project\")) {\n            var parent = json.get(\"forked_from_project\").get(\"path_with_namespace\").asString();\n            return Optional.of(new GitLabRepository(gitLabHost, parent));\n        }\n        return Optional.empty();\n    }\n\n    @Override\n    public PullRequest createPullRequest(HostedRepository target,\n                                         String targetRef,\n                                         String sourceRef,\n                                         String title,\n                                         List<String> body,\n                                         boolean draft) {\n        if (!(target instanceof GitLabRepository targetRepo)) {\n            throw new IllegalArgumentException(\"target must be a GitLab repository\");\n        }\n\n        var pr = request.post(\"merge_requests\")\n                        .body(\"source_branch\", sourceRef)\n                        .body(\"target_branch\", targetRef)\n                        .body(\"title\", (draft ? \"Draft: \" : \"\") + title)\n                        .body(\"description\", String.join(\"\\n\", body))\n                        .body(\"target_project_id\", Long.toString(target.id()))\n                        .execute();\n\n        return new GitLabMergeRequest(targetRepo, gitLabHost, pr, targetRepo.request);\n    }\n\n    @Override\n    public PullRequest pullRequest(String id) {\n        var pr = request.get(\"merge_requests/\" + id).execute();\n        return new GitLabMergeRequest(this, gitLabHost, pr, request);\n    }\n\n    // Sometimes GitLab returns merge requests that cannot be acted upon\n    private boolean hasHeadHash(JSONValue json) {\n        return json.contains(\"sha\") && !json.get(\"sha\").isNull();\n    }\n\n    @Override\n    public List<PullRequest> pullRequests() {\n        return request.get(\"merge_requests\")\n                      .param(\"order_by\", \"updated_at\")\n                      .maxPages(1)\n                      .execute().stream()\n                      .filter(this::hasHeadHash)\n                      .map(this::refetchMergeRequest)\n                      .map(value -> new GitLabMergeRequest(this, gitLabHost, value, request))\n                      .collect(Collectors.toList());\n    }\n\n    @Override\n    public List<PullRequest> openPullRequests() {\n        return request.get(\"merge_requests\")\n                      .param(\"state\", \"opened\")\n                      .execute().stream()\n                      .filter(this::hasHeadHash)\n                      .map(this::refetchMergeRequest)\n                      .map(value -> new GitLabMergeRequest(this, gitLabHost, value, request))\n                      .collect(Collectors.toList());\n    }\n\n    @Override\n    public List<PullRequest> pullRequestsAfter(ZonedDateTime updatedAfter) {\n        return request.get(\"merge_requests\")\n                      .param(\"order_by\", \"updated_at\")\n                      .param(\"updated_after\", updatedAfter.format(DateTimeFormatter.ISO_DATE_TIME))\n                      .maxPages(1)\n                      .execute().stream()\n                      .filter(this::hasHeadHash)\n                      .map(this::refetchMergeRequest)\n                      .map(value -> new GitLabMergeRequest(this, gitLabHost, value, request))\n                      .collect(Collectors.toList());\n    }\n\n    @Override\n    public List<PullRequest> openPullRequestsAfter(ZonedDateTime updatedAfter) {\n        return request.get(\"merge_requests\")\n                .param(\"state\", \"opened\")\n                .param(\"updated_after\", updatedAfter.format(DateTimeFormatter.ISO_DATE_TIME))\n                .execute().stream()\n                .filter(this::hasHeadHash)\n                .map(this::refetchMergeRequest)\n                .map(value -> new GitLabMergeRequest(this, gitLabHost, value, request))\n                .collect(Collectors.toList());\n    }\n\n    /**\n     * This method is used to work around a bug in GitLab where list query\n     * results for merge requests sometimes return stale data. Fetching them\n     * directly using the ID will always return up-to-date data. The method\n     * logs when stale data is actually detected to give us a way to\n     * empirically verify when the bug is no longer present.\n     */\n    private JSONValue refetchMergeRequest(JSONValue origData) {\n        var updatedAt = ZonedDateTime.parse(origData.get(\"updated_at\").asString());\n        // Only do the refetch on merge requests that have been updated recently.\n        // The 3 hours cut off is rather arbitrarily chosen. We will have to see\n        // if it is enough. Having some kind of cut off is reasonable as we would\n        // otherwise risk running a lot of queries on the first run after a\n        // restart.\n        if (updatedAt.isAfter(ZonedDateTime.now().minus(Duration.ofHours(3)))) {\n            var id = origData.get(\"iid\");\n            var newData = request.get(\"merge_requests/\" + id).execute();\n            // We can't compare the full json object returned from a list query\n            // and get query call as they will always be different. The part we\n            // worry about is the labels, so compare just that.\n            JSONValue origLabels = origData.get(\"labels\");\n            JSONValue newLabels = newData.get(\"labels\");\n            if (!origLabels.equals(newLabels)) {\n                log.warning(\"Possibly stale merge request data received for \" + name() + \"#\" + id\n                        + \" orig: \" + origLabels + \" new: \" + newLabels);\n            }\n            return newData;\n        } else {\n            return origData;\n        }\n    }\n\n    @Override\n    public List<PullRequest> findPullRequestsWithComment(String author, String body) {\n        throw new RuntimeException(\"not implemented yet\");\n    }\n\n    @Override\n    public Optional<PullRequest> parsePullRequestUrl(String url) {\n        var matcher = mergeRequestPattern.matcher(url);\n        if (matcher.find()) {\n            return Optional.of(pullRequest(matcher.group(1)));\n        } else {\n            return Optional.empty();\n        }\n    }\n\n    @Override\n    public String name() {\n        return projectName;\n    }\n\n    @Override\n    public String group() {\n        return projectName.split(\"/\")[0];\n    }\n\n    @Override\n    public URI authenticatedUrl() {\n        if (gitLabHost.useSsh()) {\n            return URI.create(\"ssh://git@\" + gitLabHost.getPat().orElseThrow().username() + \".\" + gitLabHost.getUri().getHost() + \"/\" + projectName + \".git\");\n        } else {\n            var builder = URIBuilder\n                    .base(gitLabHost.getUri())\n                    .setPath(\"/\" + projectName + \".git\");\n            gitLabHost.getPat().ifPresent(pat -> builder.setAuthentication(pat.username() + \":\" + pat.password()));\n            return builder.build();\n        }\n    }\n\n    @Override\n    public URI webUrl() {\n        return URIBuilder.base(gitLabHost.getUri())\n                         .setPath(\"/\" + projectName)\n                         .build();\n    }\n\n    @Override\n    public URI nonTransformedWebUrl() {\n        return webUrl();\n    }\n\n    @Override\n    public URI webUrl(Hash hash) {\n        return URIBuilder.base(gitLabHost.getUri())\n                         .setPath(\"/\" + projectName + \"/commit/\" + hash)\n                         .build();\n    }\n\n    @Override\n    public URI webUrl(String baseRef, String headRef) {\n        return URIBuilder.base(gitLabHost.getUri())\n                         .setPath(\"/\" + projectName + \"/compare/\" + baseRef + \"...\" + headRef)\n                         .build();\n    }\n\n    @Override\n    public URI diffUrl(String prId) {\n        // GitLab is too smart for it's own best and mangles URLs that contain a\n        // partial hit with the base MR, hence the double slash.\n        return URIBuilder.base(gitLabHost.getUri())\n                .setPath(\"/\" + projectName + \"/-/merge_requests//\" + prId + \".diff\")\n                .build();\n    }\n\n    @Override\n    public VCS repositoryType() {\n        return VCS.GIT;\n    }\n\n    @Override\n    public URI url() {\n        return URIBuilder.base(gitLabHost.getUri())\n                .setPath(\"/\" + projectName + \".git\")\n                .build();\n    }\n\n    @Override\n    public Optional<String> fileContents(String filename, String ref) {\n        var encodedFileName = URLEncoder.encode(filename, StandardCharsets.UTF_8);\n        var content = request.get(\"repository/files/\" + encodedFileName)\n                .param(\"ref\", ref)\n                .onError(response -> {\n                    // Retry once with additional escaping of the path fragment\n                    // Only retry when the error is exactly \"File Not Found\"\n                    // For GitLab, if ref not found, it returns \"404 Commit Not Found\",\n                    // if file not found, it returns \"404 File Not Found\"\n                    if (response.statusCode() == 404 && JSON.parse(response.body()).get(\"message\").asString().endsWith(\"File Not Found\")) {\n                        log.warning(\"First time request returned bad status: \" + response.statusCode());\n                        log.info(\"First time response body: \" + response.body());\n                        var doubleEncodedFileName = URLEncoder.encode(encodedFileName, StandardCharsets.UTF_8);\n                        return Optional.of(request.get(\"repository/files/\" + doubleEncodedFileName)\n                                .param(\"ref\", ref)\n                                .onError(r -> r.statusCode() == 404 && JSON.parse(r.body()).get(\"message\").asString().endsWith(\"File Not Found\") ?\n                                        Optional.of(JSON.object().put(\"NOT_FOUND\", true)) : Optional.empty())\n                                .execute());\n                    }\n                    return Optional.empty();\n                })\n                .execute();\n        if (content.contains(\"NOT_FOUND\")) {\n            return Optional.empty();\n        }\n        var decodedContent = Base64.getDecoder().decode(content.get(\"content\").asString());\n        return Optional.of(new String(decodedContent, StandardCharsets.UTF_8));\n    }\n\n    @Override\n    public void writeFileContents(String filename, String content, Branch branch, String message, String authorName, String authorEmail, boolean createNewFile) {\n        var encodedFileName = URLEncoder.encode(filename, StandardCharsets.UTF_8);\n        var body = JSON.object()\n                .put(\"commit_message\", message)\n                .put(\"branch\", branch.name())\n                .put(\"author_name\", authorName)\n                .put(\"author_email\", authorEmail)\n                .put(\"encoding\", \"base64\")\n                .put(\"content\", new String(Base64.getEncoder().encode(content.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8));\n\n        RestRequest.ErrorTransform onError = response -> {\n            // When GitLab returns 400, it may have still performed the update, so\n            // need to check the current file contents.\n            if (response.statusCode() == 400) {\n                log.info(\"Received status code 400 when writing file \" + filename\n                        + \" in repo \" + name() + \", checking if file was stored anyway\");\n                var currentContents = fileContents(filename, branch.name());\n                if (currentContents.isPresent() && content.equals(currentContents.get())) {\n                    // Need to return something other than empty\n                    log.info(\"Writing file \" + filename + \" in repo \" + name()\n                            + \" was found to be successful in spite of return code 400\");\n                    return Optional.of(JSON.of());\n                }\n            }\n            return Optional.empty();\n        };\n\n        if (createNewFile) {\n            // Use POST to create a new file\n            request.post(\"repository/files/\" + encodedFileName)\n                    .body(body)\n                    .timeout(Duration.ofSeconds(60))\n                    .onError(onError)\n                    .execute();\n        } else {\n            // USE PUT to update the file\n            request.put(\"repository/files/\" + encodedFileName)\n                    .body(body)\n                    .timeout(Duration.ofSeconds(60))\n                    .onError(onError)\n                    .execute();\n        }\n    }\n\n    @Override\n    public String namespace() {\n        return URIBuilder.base(gitLabHost.getUri()).build().getHost();\n    }\n\n    @Override\n    public Optional<WebHook> parseWebHook(JSONValue body) {\n        if (!body.contains(\"object_kind\")) {\n            return Optional.empty();\n        }\n        if (!body.contains(\"project\") || !body.get(\"project\").contains(\"path_with_namespace\")) {\n            return Optional.empty();\n        }\n        if (!body.get(\"project\").get(\"path_with_namespace\").asString().equals(projectName)) {\n            return Optional.empty();\n        }\n\n        int id = -1;\n\n        if (body.get(\"object_kind\").asString().equals(\"merge_request\")) {\n            if (!body.contains(\"object_attributes\") || !body.get(\"object_attributes\").contains(\"iid\")) {\n                return Optional.empty();\n            }\n            id = body.get(\"object_attributes\").get(\"iid\").asInt();\n        }\n\n        if (body.contains(\"merge_request\")) {\n            if (!body.get(\"merge_request\").contains(\"iid\")) {\n                return Optional.empty();\n            }\n            id = body.get(\"merge_request\").get(\"iid\").asInt();\n        }\n\n        if (id != -1) {\n            var pr = pullRequest(Integer.toString(id));\n            var webHook = new WebHook(List.of(pr));\n            return Optional.of(webHook);\n        } else {\n            return Optional.empty();\n        }\n    }\n\n    @Override\n    public HostedRepository fork() {\n        var namespace = gitLabHost.currentUser().username();\n        request.post(\"fork\")\n               .body(\"namespace\", namespace)\n               .onError(r -> r.statusCode() == 409 ? Optional.of(JSON.object().put(\"exists\", true)) : Optional.empty())\n               .execute();\n        var nameOnlyStart = projectName.lastIndexOf('/');\n        var nameOnly = nameOnlyStart >= 0 ? projectName.substring(nameOnlyStart + 1) : projectName;\n        var forkedRepoName = namespace + \"/\" + nameOnly;\n        while (!gitLabHost.isProjectForkComplete(forkedRepoName)) {\n            try {\n                Thread.sleep(Duration.ofSeconds(1));\n            } catch (InterruptedException e) {\n                e.printStackTrace();\n            }\n        }\n        return gitLabHost.repository(forkedRepoName).orElseThrow(RuntimeException::new);\n    }\n\n    @Override\n    public long id() {\n        return json.get(\"id\").asLong();\n    }\n\n    @Override\n    public Optional<Hash> branchHash(String ref) {\n        var branch = request.get(\"repository/branches/\" + URLEncoder.encode(ref, StandardCharsets.US_ASCII))\n                .onError(r -> r.statusCode() == 404 ? Optional.of(JSON.object().put(\"NOT_FOUND\", true)) : Optional.empty())\n                .execute();\n        if (branch.contains(\"NOT_FOUND\")) {\n            return Optional.empty();\n        }\n        return Optional.of(new Hash(branch.get(\"commit\").get(\"id\").asString()));\n    }\n\n    @Override\n    public List<HostedBranch> branches() {\n        var branches = request.get(\"repository/branches\").execute();\n        return branches.stream()\n                       .map(b -> new HostedBranch(b.get(\"name\").asString(),\n                                                  new Hash(b.get(\"commit\").get(\"id\").asString())))\n                       .collect(Collectors.toList());\n    }\n\n    @Override\n    public String defaultBranchName() {\n        return json.get(\"default_branch\").asString();\n    }\n\n    @Override\n    public void protectBranchPattern(String pattern) {\n        var body = JSON.object()\n                .put(\"name\", pattern)\n                .put(\"allow_force_push\", true);\n        var existing = request.get(\"protected_branches/\" + URLEncoder.encode(pattern, StandardCharsets.US_ASCII))\n                .onError(r -> r.statusCode() == 404 ? Optional.of(JSON.of()) : Optional.empty())\n                .execute();\n        // Only add protection if it doesn't already exist.\n        if (existing.isNull()) {\n            request.post(\"protected_branches\")\n                    .body(body)\n                    .execute();\n        }\n    }\n\n    @Override\n    public void unprotectBranchPattern(String pattern) {\n        request.delete(\"protected_branches/\" + URLEncoder.encode(pattern, StandardCharsets.US_ASCII))\n                .header(\"Content-Type\", \"application/json\")\n                .onError(r -> r.statusCode() == 404 ? Optional.of(JSON.of()) : Optional.empty())\n                .execute();\n    }\n\n    @Override\n    public void deleteBranch(String ref) {\n        request.delete(\"repository/branches/\" + URLEncoder.encode(ref, StandardCharsets.US_ASCII))\n                .header(\"Content-Type\", \"application/json\")\n                .execute();\n    }\n\n    // Handles results from both comments and discussions API\n    private CommitComment toCommitComment(Hash hash, JSONValue o) {\n        if (o.contains(\"note\")) {\n            var line = o.get(\"line\").isNull() ? -1 : o.get(\"line\").asInt();\n            var path = o.get(\"path\").isNull() ? null : Path.of(o.get(\"path\").asString());\n            // GitLab does not offer updated_at for commit comments\n            var createdAt = ZonedDateTime.parse(o.get(\"created_at\").asString());\n            var body = o.get(\"note\").asString();\n            return new CommitComment(hash,\n                    path,\n                    line,\n                    null, // The comments API does not return an ID\n                    body,\n                    gitLabHost.parseAuthorField(o),\n                    createdAt,\n                    createdAt);\n\n        } else if (o.contains(\"notes\")) {\n            var note = o.get(\"notes\").asArray().get(0);\n            var line = -1;\n            Path path = null;\n            if (note.contains(\"position\")) {\n                var position = note.get(\"position\");\n                if (!position.get(\"new_line\").isNull()) {\n                    line = position.get(\"new_line\").asInt();\n                    path = Path.of(position.get(\"new_path\").asString());\n                } else if (!position.get(\"old_line\").isNull()) {\n                    line = position.get(\"old_line\").asInt();\n                    path = Path.of(position.get(\"old_path\").asString());\n                }\n            }\n            return new CommitComment(hash,\n                    path,\n                    line,\n                    note.get(\"id\").toString(),\n                    note.get(\"body\").asString(),\n                    gitLabHost.parseAuthorField(note),\n                    ZonedDateTime.parse(note.get(\"created_at\").asString()),\n                    ZonedDateTime.parse(note.get(\"updated_at\").asString()));\n\n        } else {\n            throw new RuntimeException(\"Object contains neither 'note' or 'notes', cannot parse commit comment\");\n        }\n    }\n\n    @Override\n    public List<CommitComment> commitComments(Hash hash) {\n        // Using the discussions API gives us more information, most notably the ID field\n        return request.get(\"repository/commits/\" + hash.hex() + \"/discussions\")\n                      .execute()\n                      .stream()\n                      .map(o -> toCommitComment(hash, o))\n                      .collect(Collectors.toList());\n    }\n\n    private Set<Hash> commitsWithTitle(String commitTitle, Map<String, SequencedSet<Hash>> commitTitlesToHashes) {\n        if (commitTitlesToHashes.containsKey(commitTitle)) {\n            return commitTitlesToHashes.get(commitTitle);\n        }\n\n        if (commitTitle.endsWith(\"...\")) {\n            var candidates = new HashSet<Hash>();\n            var prefix = commitTitle.substring(0, commitTitle.length() - \"...\".length());\n            for (var title : commitTitlesToHashes.keySet()) {\n                if (title.startsWith(prefix)) {\n                    candidates.addAll(commitTitlesToHashes.get(title));\n                }\n            }\n            return candidates;\n        }\n\n        return Set.of();\n    }\n\n    private Optional<CommitComment> findComment(String commitTitle,\n            String commentId,\n            Map<String, SequencedSet<Hash>> commitTitleToCommits) {\n        var candidates = commitsWithTitle(commitTitle, commitTitleToCommits);\n        // Even if there is only one candidate, we need to make sure the comment\n        // exists on that commit before we try to process it. If this fails it's\n        // most likely due to inconsistent data from GitLab, which should\n        // eventually clear up on subsequent tries.\n        Optional<CommitComment> found = candidates.stream()\n                .flatMap(candidate -> commitComments(candidate).stream())\n                .filter(comment -> comment.id().equals(commentId))\n                .findFirst();\n        if (found.isEmpty()) {\n            log.warning(\"Did not find commit with title \" + commitTitle + \" for repository \" + projectName);\n        }\n        return found;\n    }\n\n    /**\n     * The localRepo is needed to build a map of commit title to commit hash mappings,\n     * which in turn is needed to identify commits form the GitLab notes objects. The\n     * notes only has the commit titles, not the hashes.\n     */\n    @Override\n    public List<CommitComment> recentCommitComments(ReadOnlyRepository localRepo, Set<Integer> excludeAuthors,\n            List<Branch> branches, ZonedDateTime updatedAfter) {\n        if (localRepo == null) {\n            throw new NullPointerException(\"localRepo cannot be null in GitLabMergeRequest\");\n        }\n\n        var formatter = DateTimeFormatter.ofPattern(\"yyyy-MM-dd\");\n        var notes = request.get(\"events\")\n                .param(\"after\", updatedAfter.format(formatter))\n                .execute()\n                .stream()\n                .filter(o -> o.contains(\"note\") &&\n                        o.get(\"note\").contains(\"noteable_type\") &&\n                        o.get(\"note\").get(\"noteable_type\").asString().equals(\"Commit\"))\n                .filter(o -> o.contains(\"target_type\") &&\n                        !o.get(\"target_type\").isNull() &&\n                        o.get(\"target_type\").asString().equals(\"Note\"))\n                .filter(o -> o.contains(\"author\") &&\n                        o.get(\"author\").contains(\"id\") &&\n                        !excludeAuthors.contains(o.get(\"author\").get(\"id\").asInt()))\n                .toList();\n\n        if (notes.isEmpty()) {\n            return List.of();\n        }\n\n        var commitTitleToCommits = getCommitTitleToCommitsMap(localRepo, branches);\n\n        return notes.stream()\n                .map(o -> findComment(o.get(\"target_title\").asString(),\n                        o.get(\"note\").get(\"id\").toString(), commitTitleToCommits))\n                .flatMap(Optional::stream)\n                .toList();\n    }\n\n    /**\n     * Lazy fetching and caching of the commitTitleToCommits map. The map is only\n     * cached for one set of branches. If called with a different set, the old\n     * cache is invalidated and a new is rebuilt from scratch. In practice, an\n     * instance of this class is only ever called with the same set of branches.\n     * If that was to change we will need to rethink this.\n     * <p>\n     * The full map is built from the local repository. For incremental updates,\n     * it's refreshed until it contains the same number of commits as the local\n     * repository. The topo-order should guarantee that this gives us the same\n     * set of commits.\n     */\n    private final Map<String, SequencedSet<Hash>> commitTitleToCommits = new HashMap<>();\n    private Set<Branch> commitMapBranchSet;\n    private int commitMapCount = 0;\n\n    private Map<String, SequencedSet<Hash>> getCommitTitleToCommitsMap(ReadOnlyRepository localRepo, List<Branch> branches) {\n        try {\n            Set<Branch> branchSet = Set.copyOf(branches);\n            if (!branchSet.equals(commitMapBranchSet)) {\n                log.info(\"Invalidating commitTitleToCommits map for branch set: \" + branchSet + \" old set: \" + commitMapBranchSet);\n                commitTitleToCommits.clear();\n                commitMapCount = 0;\n                commitMapBranchSet = branchSet;\n            }\n            int newSize = localRepo.commitCount(branches);\n            if (newSize > commitMapCount) {\n                int sizeBefore = commitMapCount;\n                log.info(\"Adding \" + (newSize - sizeBefore) + \" new commit(s) to commitTitleToCommits map\");\n                for (var commit : localRepo.commitMetadataFor(branches)) {\n                    var title = commit.message().stream().findFirst().orElse(\"\");\n                    SequencedSet<Hash> hashes = commitTitleToCommits.computeIfAbsent(title, t -> new LinkedHashSet<>());\n                    if (!hashes.contains(commit.hash())) {\n                        // We want to keep newer commits at the front for quicker lookup.\n                        // Commits are iterated from newer to older so add last for the\n                        // initial run. When updating, there will generally just be a small\n                        // set of new commits in each run, so add them to the front.\n                        if (sizeBefore == 0) {\n                            hashes.add(commit.hash());\n                        } else {\n                            hashes.addFirst(commit.hash());\n                        }\n                        commitMapCount++;\n                        // Only log each addition after the initial complete build is done\n                        if (sizeBefore > 0) {\n                            log.fine(\"Adding \" + commit.hash() + \" to commitTitleToCommits map\");\n                        }\n                    }\n                    if (commitMapCount >= newSize) {\n                        break;\n                    }\n                }\n            } else {\n                log.fine(\"No commits added to commitTitleToCommits map\");\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        return commitTitleToCommits;\n    }\n\n    /**\n     * The CommitComment returned from this method will not have an ID field,\n     * this is due to a limitation in the GitLab API.\n     */\n    @Override\n    public CommitComment addCommitComment(Hash hash, String body) {\n        var query = JSON.object().put(\"note\", body);\n        var result = request.post(\"repository/commits/\" + hash.hex() + \"/comments\")\n                            .body(query)\n                            .execute();\n        return toCommitComment(hash, result);\n    }\n\n    @Override\n    public void updateCommitComment(String id, String body) {\n        throw new RuntimeException(\"not implemented yet\");\n    }\n\n    private CommitMetadata toCommitMetadata(JSONValue o) {\n        var hash = new Hash(o.get(\"id\").asString());\n        var parents = o.get(\"parent_ids\").stream()\n                                      .map(JSONValue::asString)\n                                      .map(Hash::new)\n                                      .collect(Collectors.toList());\n        var author = new Author(o.get(\"author_name\").asString(),\n                                o.get(\"author_email\").asString());\n        var authored = ZonedDateTime.parse(o.get(\"authored_date\").asString());\n        var committer = new Author(o.get(\"committer_name\").asString(),\n                                   o.get(\"committer_email\").asString());\n        var committed = ZonedDateTime.parse(o.get(\"committed_date\").asString());\n        var message = Arrays.asList(o.get(\"message\").asString().split(\"\\n\"));\n        return new CommitMetadata(hash, parents, author, authored, committer, committed, message);\n    }\n\n    Diff toDiff(Hash from, Hash to, JSONValue o, boolean complete) {\n        var patches = new ArrayList<Patch>();\n\n        for (var file : o.asArray()) {\n            Path sourcePath = null;\n            FileType sourceFileType = null;\n            Path targetPath = null;\n            FileType targetFileType = null;\n            Status status = null;\n\n            if (file.get(\"new_file\").asBoolean()) {\n                status = Status.from('A');\n                targetPath = Path.of(file.get(\"new_path\").asString());\n                targetFileType = FileType.fromOctal(file.get(\"b_mode\").asString());\n            } else if (file.get(\"renamed_file\").asBoolean()) {\n                status = Status.from('R');\n                sourcePath = Path.of(file.get(\"old_path\").asString());\n                sourceFileType = FileType.fromOctal(file.get(\"a_mode\").asString());\n                targetPath = Path.of(file.get(\"new_path\").asString());\n                targetFileType = FileType.fromOctal(file.get(\"b_mode\").asString());\n            } else if (file.get(\"deleted_file\").asBoolean()) {\n                status = Status.from('D');\n                sourcePath = Path.of(file.get(\"old_path\").asString());\n                sourceFileType = FileType.fromOctal(file.get(\"a_mode\").asString());\n            } else {\n                status = Status.from('M');\n                sourcePath = Path.of(file.get(\"old_path\").asString());\n                sourceFileType = FileType.fromOctal(file.get(\"a_mode\").asString());\n                targetPath = Path.of(file.get(\"new_path\").asString());\n                targetFileType = FileType.fromOctal(file.get(\"b_mode\").asString());\n            }\n\n            var diff = file.get(\"diff\").asString();\n            var hunks = diff.isEmpty() ?\n                new ArrayList<Hunk>() :\n                UnifiedDiffParser.parseSingleFileDiff(diff.split(\"\\n\"));\n\n            patches.add(new TextualPatch(sourcePath, sourceFileType, Hash.zero(),\n                                         targetPath, targetFileType, Hash.zero(),\n                                         status, hunks));\n        }\n\n        return new Diff(from, to, patches, complete);\n    }\n\n    @Override\n    public Optional<HostedCommit> commit(Hash hash, boolean includeDiffs) {\n        var c = request.get(\"repository/commits/\" + hash.hex())\n                       .onError(r -> Optional.of(JSON.of()))\n                       .execute();\n        if (c.isNull()) {\n            return Optional.empty();\n        }\n        var url = URI.create(c.get(\"web_url\").asString());\n        var metadata = toCommitMetadata(c);\n\n        List<Diff> diffs = List.of();\n        if (includeDiffs) {\n            var diff = request.get(\"repository/commits/\" + hash.hex() + \"/diff\")\n                    .onError(r -> Optional.of(JSON.of()))\n                    .execute();\n            if (!diff.isNull()) {\n                // The diff here is limited by diff patch, diff files count, diff lines.\n                // There is no feasible way to know if this diff is complete.\n                // The diff here is only used to evaluate if a backport PR is clean or not,\n                // assuming the diff is complete will not introduce any side effect.\n                diffs = List.of(toDiff(metadata.parents().get(0), hash, diff, true));\n            }\n        }\n        return Optional.of(new HostedCommit(metadata, diffs, url));\n    }\n\n    @Override\n    public List<Check> allChecks(Hash hash) {\n        return List.of();\n    }\n\n    @Override\n    public WorkflowStatus workflowStatus() {\n        if (json.contains(\"jobs_enabled\")) {\n            return json.get(\"jobs_enabled\").asBoolean() ? WorkflowStatus.ENABLED : WorkflowStatus.DISABLED;\n        } else {\n            return WorkflowStatus.DISABLED;\n        }\n    }\n\n    @Override\n    public URI webUrl(Branch branch) {\n        var endpoint = \"/\" + projectName + \"/-/tree/\" + branch.name();\n        return gitLabHost.getWebUri(endpoint);\n    }\n\n    @Override\n    public URI webUrl(Tag tag) {\n        var endpoint = \"/\" + projectName + \"/-/tags/\" + tag.name();\n        return gitLabHost.getWebUri(endpoint);\n    }\n\n    @Override\n    public URI createPullRequestUrl(HostedRepository target, String targetRef, String sourceRef) {\n        var id = json.get(\"id\").asInt();\n        var targetId = ((GitLabRepository) target).json.get(\"id\").asInt();\n        var endpoint = \"/\" + projectName + \"/-/merge_requests/new?\" +\n                       \"merge_request[source_project_id]=\" + id +\n                       \"&merge_request[source_branch]=\" + sourceRef +\n                       \"&merge_request[target_project]=\" + targetId +\n                       \"&merge_request[target_branch]=\" + targetRef;\n        return gitLabHost.getWebUri(endpoint);\n    }\n\n    @Override\n    public List<Collaborator> collaborators() {\n        var result = request.get(\"members\").execute();\n        return result.stream()\n                .map(o -> new Collaborator(gitLabHost.parseAuthorObject(o.asObject()), o.get(\"access_level\").asInt() >= 30))\n                .toList();\n    }\n\n    @Override\n    public void addCollaborator(HostUser user, boolean canPush) {\n        var accessLevel = canPush ? \"30\" : \"20\";\n        request.post(\"members\")\n               .body(\"user_id\", user.id())\n               .body(\"access_level\", accessLevel)\n               .execute();\n    }\n\n    @Override\n    public void removeCollaborator(HostUser user) {\n        request.delete(\"members/\" + user.id())\n                .header(\"Content-Type\", \"application/json\")\n                .execute();\n    }\n\n    @Override\n    public boolean canPush(HostUser user) {\n        var accessLevel = request.get(\"members/all/\" + user.id())\n                                 .onError(r -> r.statusCode() == 404 ?\n                                                   Optional.of(JSON.object().put(\"access_level\", 0)) :\n                                                   Optional.empty())\n                                 .execute()\n                                 .get(\"access_level\")\n                                 .asInt();\n        return accessLevel >= 30;\n    }\n\n    @Override\n    public void restrictPushAccess(Branch branch, HostUser user) {\n        // Not possible to implement using GitLab Community Edition.\n        // Must work around in admin web UI using groups.\n    }\n\n    @Override\n    public List<Label> labels() {\n        return request.get(\"labels\")\n                      .execute()\n                      .stream()\n                      .map(o -> new Label(o.get(\"name\").asString(), o.get(\"description\").asString()))\n                      .collect(Collectors.toList());\n    }\n\n    @Override\n    public void addLabel(Label label) {\n        var params = JSON.object()\n                .put(\"name\", label.name())\n                // Color is Blue-Gray and matches all current labels\n                .put(\"color\", \"#428BCA\");\n        if (label.description().isPresent()) {\n            params.put(\"description\", label.description().get());\n        }\n        request.post(\"labels\")\n                .body(params)\n                .execute();\n    }\n\n    @Override\n    public void updateLabel(Label label) {\n        var params = JSON.object()\n                .put(\"new_name\", label.name());\n        if (label.description().isPresent()) {\n            params.put(\"description\", label.description().get());\n        } else {\n            throw new UnsupportedOperationException(\"Gitlab does not support clearing the description\");\n        }\n        request.put(\"labels/\" + label.name())\n                .body(params)\n                .execute();\n    }\n\n    @Override\n    public void deleteLabel(Label label) {\n        request.delete(\"labels/\" + label.name())\n                .execute();\n    }\n\n    @Override\n    public int deleteDeployKeys(Duration age) {\n        var expiredKeys = request.get(\"deploy_keys\").execute()\n                .stream()\n                .filter(key -> ZonedDateTime.parse(key.get(\"created_at\").asString())\n                        .isBefore(ZonedDateTime.now().minus(age)))\n                .toList();\n        for (var key : expiredKeys) {\n            request.delete(\"deploy_keys/\" + key.get(\"id\"))\n                    .header(\"Content-Type\", \"application/json\")\n                    .execute();\n        }\n        return expiredKeys.size();\n    }\n\n    @Override\n    public boolean canCreatePullRequest(HostUser user) {\n        return canPush(user);\n    }\n\n    @Override\n    public List<PullRequest> openPullRequestsWithTargetRef(String targetRef) {\n        return request.get(\"merge_requests\")\n                .param(\"state\", \"opened\")\n                .param(\"target_branch\", targetRef)\n                .execute().stream()\n                .filter(this::hasHeadHash)\n                .map(this::refetchMergeRequest)\n                .map(value -> new GitLabMergeRequest(this, gitLabHost, value, request))\n                .collect(Collectors.toList());\n    }\n\n    @Override\n    public List<String> deployKeyTitles(Duration age) {\n        return request.get(\"deploy_keys\").execute()\n                .stream()\n                .filter(key -> ZonedDateTime.parse(key.get(\"created_at\").asString())\n                        .isBefore(ZonedDateTime.now().minus(age)))\n                .map(key -> key.get(\"title\").asString())\n                .toList();\n    }\n}\n"
  },
  {
    "path": "forge/src/main/java/org/openjdk/skara/forge/internal/ForgeUtils.java",
    "content": "/*\n * Copyright (c) 2023, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.internal;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.regex.Pattern;\n\npublic class ForgeUtils {\n\n    /**\n     * Adds a special ssh key configuration in the user's ssh config file.\n     * The config will only apply to the fake host userName.hostName so should\n     * not interfere with other user configurations. The caller of this method\n     * needs to use userName.hostName as host name when calling ssh.\n     */\n    public static void configureSshKey(String userName, String hostName, String sshKeyFile) {\n        var cfgPath = Path.of(System.getProperty(\"user.home\"), \".ssh\");\n        if (!Files.isDirectory(cfgPath)) {\n            try {\n                Files.createDirectories(cfgPath);\n            } catch (IOException ignored) {\n            }\n        }\n\n        var cfgFile = cfgPath.resolve(\"config\");\n        var existing = \"\";\n        try {\n            existing = Files.readString(cfgFile);\n        } catch (IOException ignored) {\n        }\n\n        var userHost = userName + \".\" + hostName;\n        var existingBlock = Pattern.compile(\"^Match host \" + Pattern.quote(userHost) + \"(?:\\\\R[ \\\\t]+.*)+\", Pattern.MULTILINE);\n        var existingMatcher = existingBlock.matcher(existing);\n        var filtered = existingMatcher.replaceAll(\"\");\n        var result = \"Match host \" + userHost + \"\\n\" +\n                \"  Hostname \" + hostName + \"\\n\" +\n                \"  PreferredAuthentications publickey\\n\" +\n                \"  StrictHostKeyChecking no\\n\" +\n                \"  IdentityFile \" + sshKeyFile + \"\\n\" +\n                \"\\n\";\n\n        try {\n            Files.writeString(cfgFile, result + filtered.strip() + \"\\n\");\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/CheckBuilderTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.openjdk.skara.vcs.Hash;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.time.ZonedDateTime;\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass CheckBuilderTests {\n    @Test\n    void testFrom() {\n        var name = \"test\";\n        var title = \"title\";\n        var summary = \"summary\";\n        var metadata = \"metadata\";\n        var annotation = CheckAnnotationBuilder.create(\"README\", 0, 1, CheckAnnotationLevel.NOTICE, \"Message\")\n                                               .build();\n        var startedAt = ZonedDateTime.now();\n        var completedAt = ZonedDateTime.now();\n        var success = true;\n\n        var existing = CheckBuilder.create(name, Hash.zero())\n                                   .title(title)\n                                   .summary(summary)\n                                   .metadata(metadata)\n                                   .annotation(annotation)\n                                   .startedAt(startedAt)\n                                   .complete(success, completedAt)\n                                   .build();\n        var dup = CheckBuilder.from(existing)\n                              .build();\n\n        assertEquals(existing.name(), dup.name());\n        assertEquals(existing.hash(), dup.hash());\n        assertEquals(existing.status(), dup.status());\n        assertEquals(existing.startedAt(), dup.startedAt());\n        assertEquals(existing.completedAt(), dup.completedAt());\n        assertEquals(existing.title(), dup.title());\n        assertEquals(existing.summary(), dup.summary());\n        assertEquals(existing.metadata(), dup.metadata());\n        assertEquals(existing.annotations(), dup.annotations());\n\n        var newTitle = \"new title\";\n        var newSummary = \"new summary\";\n        var newMetadata = \"new metadata\";\n        var newAnnotation = CheckAnnotationBuilder.create(\"FILE\", 0, 1, CheckAnnotationLevel.NOTICE, \"Message\")\n                                                  .build();\n        var newStartedAt = ZonedDateTime.now();\n        var newCompletedAt = ZonedDateTime.now();\n        var newSuccess = false;\n\n        var modified = CheckBuilder.from(existing)\n                                   .title(newTitle)\n                                   .summary(newSummary)\n                                   .metadata(newMetadata)\n                                   .annotation(newAnnotation)\n                                   .startedAt(newStartedAt)\n                                   .complete(newSuccess, newCompletedAt)\n                                   .build();\n\n        // existing check should not have changed\n        assertEquals(dup.name(), existing.name());\n        assertEquals(dup.hash(), existing.hash());\n        assertEquals(dup.status(), existing.status());\n        assertEquals(dup.startedAt(), existing.startedAt());\n        assertEquals(dup.completedAt(), existing.completedAt());\n        assertEquals(dup.title(), existing.title());\n        assertEquals(dup.summary(), existing.summary());\n        assertEquals(dup.metadata(), existing.metadata());\n        assertEquals(dup.annotations(), existing.annotations());\n\n        // modified should have new values except name and hash and inherit annotations\n        assertEquals(existing.name(), modified.name());\n        assertEquals(existing.hash(), modified.hash());\n        assertEquals(newStartedAt, modified.startedAt());\n        assertEquals(newCompletedAt, modified.completedAt().get());\n        assertEquals(newTitle, modified.title().get());\n        assertEquals(newSummary, modified.summary().get());\n        assertEquals(newMetadata, modified.metadata().get());\n        assertEquals(List.of(annotation, newAnnotation), modified.annotations());\n    }\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/ForgeIntegrationTests.java",
    "content": "/*\n * Copyright (c) 2021, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.forge.github.GitHubApplication;\nimport org.openjdk.skara.forge.github.GitHubHost;\nimport org.openjdk.skara.forge.gitlab.GitLabHost;\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.issuetracker.Label;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.proxy.HttpProxy;\nimport org.openjdk.skara.test.TestProperties;\nimport org.openjdk.skara.test.EnabledIfTestProperties;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Paths;\nimport java.util.List;\nimport java.util.Set;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass ForgeIntegrationTests {\n    private static TestProperties props;\n\n    @BeforeAll\n    static void beforeAll() {\n        HttpProxy.setup();\n        props = TestProperties.load();\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.app.id\", \"github.app.installation\", \"github.app.key.file\", \"github.repository\"})\n    void gitHubLabels() throws IOException {\n        var uri = URIBuilder.base(\"https://github.com/\").build();\n        var id = props.get(\"github.app.id\");\n        var installation = props.get(\"github.app.installation\");\n        var keyFile = Paths.get(props.get(\"github.app.key.file\"));\n\n        var keyContents = Files.readString(keyFile);\n        var app = new GitHubApplication(keyContents, id, installation);\n        var gitHubHost = new GitHubHost(uri, app, null, null, null, Set.of(), null);\n\n        var repo = gitHubHost.repository(props.get(\"github.repository\")).orElseThrow();\n\n        verifyLabels(repo, true);\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.uri\", \"gitlab.user\", \"gitlab.pat\", \"gitlab.repository\"})\n    void gitLabLabels() throws IOException {\n        var uri = URIBuilder.base(props.get(\"gitlab.uri\")).build();\n        var user = props.get(\"gitlab.user\");\n        var pat = props.get(\"gitlab.pat\");\n        var credential = new Credential(user, pat);\n\n        var gitLabHost = new GitLabHost(\"gitlab\", uri, false, credential, List.of(), null);\n\n        var repo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n\n        verifyLabels(repo, false);\n    }\n\n    private void verifyLabels(HostedRepository repo, boolean supportsDeleteDescription) {\n        var labels = repo.labels();\n        var labelName = \"skara-test-label\";\n        var label1 = new Label(labelName, \"bar\");\n        // If the label is already there\n        if (labels.stream().anyMatch(l -> l.name().equals(labelName))) {\n            repo.deleteLabel(label1);\n        }\n        repo.addLabel(label1);\n        labels = repo.labels();\n        assertTrue(labels.contains(label1));\n\n        var label2 = new Label(labelName, \"new description\");\n        repo.updateLabel(label2);\n        labels = repo.labels();\n        assertTrue(labels.contains(label2));\n        assertFalse(labels.contains(label1));\n\n        var label3 = new Label(labelName, null);\n        if (supportsDeleteDescription) {\n            repo.updateLabel(label3);\n            labels = repo.labels();\n            assertTrue(labels.contains(label3));\n            assertFalse(labels.contains(label2));\n            assertFalse(labels.contains(label1));\n        }\n\n        repo.deleteLabel(label3);\n        labels = repo.labels();\n        assertFalse(labels.contains(label3));\n        assertFalse(labels.contains(label2));\n        assertFalse(labels.contains(label1));\n    }\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/ForgeTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.json.JSONObject;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestHost;\nimport org.openjdk.skara.test.TestHostedRepository;\nimport org.openjdk.skara.test.TestableRepository;\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.Repository;\nimport org.openjdk.skara.vcs.VCS;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.file.Files;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNull;\n\nclass ForgeTests {\n    @Test\n    void sortTest() {\n        var allFactories = List.of(new ForgeFactory() {\n                                       @Override\n                                       public String name() {\n                                           return \"something\";\n                                       }\n\n                                       @Override\n                                       public Set<String> knownHosts() {\n                                           return Set.of();\n                                       }\n\n                                       @Override\n                                       public Forge create(URI uri, Credential credential, JSONObject configuration) {\n                                           return null;\n                                       }\n                                   },\n                                   new ForgeFactory() {\n                                       @Override\n                                       public String name() {\n                                           return \"other\";\n                                       }\n\n                                       @Override\n                                       public Set<String> knownHosts() {\n                                           return Set.of();\n                                       }\n\n                                       @Override\n                                       public Forge create(URI uri, Credential credential, JSONObject configuration) {\n                                           return null;\n                                       }\n                                   });\n\n        var sorted = allFactories.stream()\n                                 .sorted(Comparator.comparing(f -> !f.name().contains(\"other\")))\n                                 .collect(Collectors.toList());\n\n        assertEquals(\"something\", allFactories.get(0).name());\n        assertEquals(\"other\", sorted.get(0).name());\n    }\n\n    private static Hash createCommit(Repository r) throws IOException {\n        var readme = r.root().resolve(\"README\");\n        Files.write(readme, List.of(\"Hello, readme!\"));\n\n        r.add(readme);\n        return r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n    }\n\n    @Test\n    void reviewUrlTest() throws IOException {\n        try (var tmp = new TemporaryDirectory()) {\n            var gitLocalDir = tmp.path().resolve(\"review.git\");\n            Files.createDirectories(gitLocalDir);\n            var gitLocalRepo = TestableRepository.init(gitLocalDir, VCS.GIT);\n            var hash = createCommit(gitLocalRepo);\n\n            var host = TestHost.createNew(List.of(HostUser.create(0, \"duke\", \"J. Duke\")));\n            var gitHostedRepo = new TestHostedRepository(host, \"review\", gitLocalRepo);\n\n            var missingReviewUrl = gitHostedRepo.reviewUrl(hash);\n            assertNull(missingReviewUrl);\n\n            gitHostedRepo.addCommitComment(hash, \"\"\"\n                    <!-- COMMIT COMMENT NOTIFICATION -->\n                    ### Review\n\n                     - [openjdk/skara/123](https://git.openjdk.org/skara/pull/123)\n                    \"\"\");\n\n            var reviewUrl = gitHostedRepo.reviewUrl(hash);\n            assertEquals(URI.create(\"https://git.openjdk.org/skara/pull/123\"), reviewUrl);\n        }\n    }\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/HostedRepositoryPoolTests.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.IOException;\nimport java.util.Optional;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class HostedRepositoryPoolTests {\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var sourceFolder = new TemporaryDirectory();\n             var seedFolder = new TemporaryDirectory();\n             var cloneFolder = new TemporaryDirectory()) {\n            var source = credentials.getHostedRepository();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(sourceFolder.path(), source.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, source.authenticatedUrl(), \"master\", true);\n\n            // Push something else\n            var hash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(hash, source.authenticatedUrl(), \"master\");\n\n            var pool = new HostedRepositoryPool(seedFolder.path());\n            var clone = pool.checkout(source, hash.hex(), cloneFolder.path());\n            assertTrue(CheckableRepository.hasBeenEdited(clone));\n        }\n    }\n\n    @Test\n    void simpleBare(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var sourceFolder = new TemporaryDirectory();\n             var seedFolder = new TemporaryDirectory();\n             var cloneFolder = new TemporaryDirectory()) {\n            var source = credentials.getHostedRepository();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(sourceFolder.path(), source.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, source.authenticatedUrl(), \"master\", true);\n\n            var pool = new HostedRepositoryPool(seedFolder.path());\n            var bareClone = pool.materializeBare(source, cloneFolder.path());\n\n            // Push something else\n            var hash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(hash, source.authenticatedUrl(), \"master\");\n\n            // The commit should not appear from this\n            bareClone = pool.materializeBare(source, cloneFolder.path());\n            var bareCommit = bareClone.lookup(hash);\n            assertEquals(Optional.empty(), bareCommit);\n\n            // But should be possible to fetch\n            bareClone.fetchAll(source.authenticatedUrl());\n            bareCommit = bareClone.lookup(hash);\n            assertEquals(bareCommit.get().hash(), hash);\n        }\n    }\n\n    @Test\n    void emptyExisting(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var sourceFolder = new TemporaryDirectory();\n             var seedFolder = new TemporaryDirectory();\n             var cloneFolder = new TemporaryDirectory()) {\n            var source = credentials.getHostedRepository();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(sourceFolder.path(), source.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, source.authenticatedUrl(), \"master\", true);\n\n            var pool = new HostedRepositoryPool(seedFolder.path());\n            var empty = TestableRepository.init(cloneFolder.path(), VCS.GIT);\n            assertThrows(IOException.class, () -> empty.checkout(new Branch(\"master\"), true));\n            var clone = pool.checkout(source, \"master\", cloneFolder.path());\n            assertFalse(CheckableRepository.hasBeenEdited(clone));\n        }\n    }\n\n    @Test\n    void partialExisting(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var sourceFolder = new TemporaryDirectory();\n             var seedFolder = new TemporaryDirectory();\n             var cloneFolder = new TemporaryDirectory()) {\n            var source = credentials.getHostedRepository();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(sourceFolder.path(), source.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, source.authenticatedUrl(), \"master\", true);\n\n            var pool = new HostedRepositoryPool(seedFolder.path());\n            var clone = pool.checkout(source, \"master\", cloneFolder.path());\n            assertFalse(CheckableRepository.hasBeenEdited(clone));\n\n            var updatedClone = pool.checkout(source, \"master\", cloneFolder.path());\n            assertFalse(CheckableRepository.hasBeenEdited(updatedClone));\n\n            // Push something else\n            var hash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(hash, source.authenticatedUrl(), \"master\");\n\n            updatedClone = pool.checkout(source, \"master\", cloneFolder.path());\n            assertTrue(CheckableRepository.hasBeenEdited(updatedClone));\n        }\n    }\n\n    @Test\n    void partialExistingAllowStale(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var sourceFolder = new TemporaryDirectory();\n             var seedFolder = new TemporaryDirectory();\n             var cloneFolder = new TemporaryDirectory()) {\n            var source = credentials.getHostedRepository();\n\n            // Populate the projects repository\n            var localRepo = CheckableRepository.init(sourceFolder.path(), source.repositoryType());\n            var masterHash = localRepo.resolve(\"master\").orElseThrow();\n            localRepo.push(masterHash, source.authenticatedUrl(), \"master\", true);\n\n            var pool = new HostedRepositoryPool(seedFolder.path());\n            var clone = pool.checkout(source, \"master\", cloneFolder.path());\n            assertFalse(CheckableRepository.hasBeenEdited(clone));\n\n            var updatedClone = pool.checkoutAllowStale(source, \"master\", cloneFolder.path());\n            assertFalse(CheckableRepository.hasBeenEdited(updatedClone));\n\n            // Push something else\n            var hash = CheckableRepository.appendAndCommit(localRepo);\n            localRepo.push(hash, source.authenticatedUrl(), \"master\");\n\n            updatedClone = pool.checkoutAllowStale(source, \"master\", cloneFolder.path());\n            assertFalse(CheckableRepository.hasBeenEdited(updatedClone));\n        }\n    }\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/LabelConfigurationTests.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\npublic class LabelConfigurationTests {\n    @Test\n    void simple() {\n        var config = LabelConfigurationJson.builder()\n                                           .addMatchers(\"1\", List.of(Pattern.compile(\"cpp$\")))\n                                           .addMatchers(\"2\", List.of(Pattern.compile(\"hpp$\")))\n                                           .build();\n\n        assertEquals(Set.of(\"1\", \"2\"), config.allowed());\n\n        assertEquals(Set.of(\"1\"), config.label(Set.of(Path.of(\"a.cpp\"))));\n        assertEquals(Set.of(\"2\"), config.label(Set.of(Path.of(\"a.hpp\"))));\n        assertEquals(Set.of(\"1\", \"2\"), config.label(Set.of(Path.of(\"a.cpp\"), Path.of(\"a.hpp\"))));\n    }\n\n    @Test\n    void group() {\n        var config = LabelConfigurationJson.builder()\n                                           .addMatchers(\"1\", List.of(Pattern.compile(\"cpp$\")))\n                                           .addMatchers(\"2\", List.of(Pattern.compile(\"hpp$\")))\n                                           .addGroup(\"both\", List.of(\"1\", \"2\"))\n                                           .build();\n\n        assertEquals(Set.of(\"1\", \"2\", \"both\"), config.allowed());\n\n        assertEquals(Set.of(\"1\"), config.label(Set.of(Path.of(\"a.cpp\"))));\n        assertEquals(Set.of(\"2\"), config.label(Set.of(Path.of(\"a.hpp\"))));\n        assertEquals(Set.of(\"both\"), config.label(Set.of(Path.of(\"a.cpp\"), Path.of(\"a.hpp\"))));\n    }\n\n    @Test\n    void groupAndSingle() {\n        var config = LabelConfigurationJson.builder()\n                                           .addMatchers(\"1\", List.of(Pattern.compile(\"cpp$\")))\n                                           .addMatchers(\"both\", List.of(Pattern.compile(\"hpp$\")))\n                                           .addGroup(\"both\", List.of(\"1\", \"2\"))\n                                           .build();\n\n        assertEquals(Set.of(\"1\", \"both\"), config.allowed());\n\n        assertEquals(Set.of(\"1\"), config.label(Set.of(Path.of(\"a.cpp\"))));\n        assertEquals(Set.of(\"both\"), config.label(Set.of(Path.of(\"a.hpp\"))));\n        assertEquals(Set.of(\"both\"), config.label(Set.of(Path.of(\"a.cpp\"), Path.of(\"a.hpp\"))));\n\n    }\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/PullRequestBodyTests.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.net.URI;\nimport java.util.List;\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass PullRequestBodyTests {\n    @Test\n    void parseEmpty() {\n        var body = PullRequestBody.parse(List.of());\n        assertTrue(body.issues().isEmpty());\n        assertTrue(body.contributors().isEmpty());\n    }\n\n    @Test\n    void parseText() {\n        var text = List.of(\n            \"Hi all,\",\n            \"\",\n            \"please review this patch!\",\n            \"\"\n        );\n        var body = PullRequestBody.parse(text);\n        assertTrue(body.issues().isEmpty());\n        assertTrue(body.contributors().isEmpty());\n    }\n\n    @Test\n    void parseEmptySections() {\n        var text = List.of(\n            \"### Issues\",\n            \"\",\n            \"### Contributors\",\n            \"\"\n        );\n        var body = PullRequestBody.parse(text);\n        assertTrue(body.issues().isEmpty());\n        assertTrue(body.contributors().isEmpty());\n    }\n\n    @Test\n    void parseSingleIssue() {\n        var text = List.of(\n            \"### Issues\",\n            \" * [JDK-1234567](https://bugs/JDK-1234567): A bug\"\n        );\n        var body = PullRequestBody.parse(text);\n        assertTrue(body.contributors().isEmpty());\n        assertEquals(1, body.issues().size());\n        assertEquals(URI.create(\"https://bugs/JDK-1234567\"),\n                     body.issues().get(0));\n    }\n\n    @Test\n    void parseMultipleIssues() {\n        var text = List.of(\n            \"### Issues\",\n            \" * [JDK-1234567](https://bugs/JDK-1234567): A bug\",\n            \" * [JDK-4567890](https://bugs/JDK-4567890): Another bug\"\n        );\n        var body = PullRequestBody.parse(text);\n        assertTrue(body.contributors().isEmpty());\n        assertEquals(2, body.issues().size());\n        assertEquals(URI.create(\"https://bugs/JDK-1234567\"),\n                     body.issues().get(0));\n        assertEquals(URI.create(\"https://bugs/JDK-4567890\"),\n                     body.issues().get(1));\n    }\n\n    @Test\n    void parseSingleContributor() {\n        var text = List.of(\n            \"### Contributors\",\n            \" * Foo Bar `<foo@bar.com>`\"\n        );\n        var body = PullRequestBody.parse(text);\n        assertTrue(body.issues().isEmpty());\n        assertEquals(1, body.contributors().size());\n        assertEquals(\"Foo Bar <foo@bar.com>\", body.contributors().get(0));\n    }\n\n    @Test\n    void parseMultipleContributors() {\n        var text = List.of(\n            \"### Contributors\",\n            \" * Foo Bar `<foo@bar.com>`\",\n            \" * J Duke `<j@duke.com>`\"\n        );\n        var body = PullRequestBody.parse(text);\n        assertTrue(body.issues().isEmpty());\n        assertEquals(2, body.contributors().size());\n        assertEquals(\"Foo Bar <foo@bar.com>\", body.contributors().get(0));\n        assertEquals(\"J Duke <j@duke.com>\", body.contributors().get(1));\n    }\n\n    @Test\n    void parseMultipleContributorsAndMultipleIssues() {\n        var text = List.of(\n            \"### Issues\",\n            \" * [JDK-1234567](https://bugs/JDK-1234567): A bug\",\n            \" * [JDK-4567890](https://bugs/JDK-4567890): Another bug\",\n            \"### Contributors\",\n            \" * Foo Bar `<foo@bar.com>`\",\n            \" * J Duke `<j@duke.com>`\"\n        );\n        var body = PullRequestBody.parse(text);\n        assertEquals(URI.create(\"https://bugs/JDK-1234567\"),\n                     body.issues().get(0));\n        assertEquals(URI.create(\"https://bugs/JDK-4567890\"),\n                     body.issues().get(1));\n        assertEquals(2, body.contributors().size());\n        assertEquals(\"Foo Bar <foo@bar.com>\", body.contributors().get(0));\n        assertEquals(\"J Duke <j@duke.com>\", body.contributors().get(1));\n    }\n\n    @Test\n    void parseMultipleContributorsAndMultipleIssuesWithAdditionalText() {\n        var text = List.of(\n            \"Hi all,\",\n            \"\",\n            \"please review this patch!\",\n            \"\",\n            \"<!-- A COMMENT ->\",\n            \"### Progress\",\n            \"\",\n            \"### Issues\",\n            \" * [JDK-1234567](https://bugs/JDK-1234567): A bug\",\n            \" * [JDK-4567890](https://bugs/JDK-4567890): Another bug\",\n            \"\",\n            \"### Contributors\",\n            \" * Foo Bar `<foo@bar.com>`\",\n            \" * J Duke `<j@duke.com>`\",\n            \"\",\n            \"### Download\"\n        );\n        var body = PullRequestBody.parse(text);\n        assertEquals(URI.create(\"https://bugs/JDK-1234567\"),\n                     body.issues().get(0));\n        assertEquals(URI.create(\"https://bugs/JDK-4567890\"),\n                     body.issues().get(1));\n        assertEquals(2, body.contributors().size());\n        assertEquals(\"Foo Bar <foo@bar.com>\", body.contributors().get(0));\n        assertEquals(\"J Duke <j@duke.com>\", body.contributors().get(1));\n    }\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/PullRequestPollerTests.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.ZonedDateTime;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.test.HostCredentials;\nimport org.openjdk.skara.test.TestHost;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class PullRequestPollerTests {\n\n    @Test\n    void onlyOpen(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = credentials.getHostedRepository();\n            var prPoller = new PullRequestPoller(repo, false);\n\n            // Create closed PR that should never be returned\n            var prClosed = credentials.createPullRequest(repo, null, null, \"Foo\");\n            prClosed.setState(Issue.State.CLOSED);\n            var prs = prPoller.updatedPullRequests();\n            assertEquals(0, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Create a PR and poll for it\n            var pr = credentials.createPullRequest(repo, null, null, \"Foo\");\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n\n            // Poll for it again without calling lastBatchHandled(), it should be returned again\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Poll for it again after calling lastBatchHandled(), it should not be returned\n            prs = prPoller.updatedPullRequests();\n            assertEquals(0, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Add a label and poll again.\n            pr.addLabel(\"foo\");\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            prPoller.lastBatchHandled();\n        }\n    }\n\n    @Test\n    void includeClosed(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = credentials.getHostedRepository();\n            var prPoller = new PullRequestPoller(repo, true);\n\n            // Create a and an open closed PR, that should both be returned\n            var prClosed = credentials.createPullRequest(repo, null, null, \"Foo\");\n            var prOpen = credentials.createPullRequest(repo, null, null, \"Foo\");\n            prClosed.setState(Issue.State.CLOSED);\n            var prs = prPoller.updatedPullRequests();\n            assertEquals(2, prs.size());\n\n            // Poll for it again without calling lastBatchHandled(), both should be returned again\n            prs = prPoller.updatedPullRequests();\n            assertEquals(2, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Poll for it again after calling lastBatchHandled(), none should not be returned\n            prs = prPoller.updatedPullRequests();\n            assertEquals(0, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Add a label to the closed PR and poll again.\n            prClosed.addLabel(\"foo\");\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            assertEquals(prClosed.id(), prs.get(0).id());\n            prPoller.lastBatchHandled();\n\n            // Add a label to the open PR and poll again.\n            prOpen.addLabel(\"foo\");\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            assertEquals(prOpen.id(), prs.get(0).id());\n            prPoller.lastBatchHandled();\n        }\n    }\n\n    /**\n     * Tests polling with padding needed, with comments and reviews irrelevant.\n     * Uses a closed PR to cover that case in our of the queryPadding tests.\n     */\n    @Test\n    void queryPaddingLabel(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = credentials.getHostedRepository();\n            var forge = repo.forge();\n            ((TestHost) forge).setMinTimeStampUpdateInterval(Duration.ofDays(1));\n            var prPoller = new PullRequestPoller(repo, true);\n\n            // Create a closed PR and poll for it\n            var pr = credentials.createPullRequest(repo, \"master\", \"master\", \"Foo\");\n            pr.setState(Issue.State.CLOSED);\n            var prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Poll for it again\n            prs = prPoller.updatedPullRequests();\n            assertEquals(0, prs.size());\n            assertFalse(prPoller.getCurrentQueryResult().pullRequests().isEmpty());\n            prPoller.lastBatchHandled();\n\n            // Add a new label but make sure the updatedAt time was not updated. This should trigger an update.\n            var prevUpdatedAt = pr.updatedAt();\n            pr.addLabel(\"foo\");\n            pr.store().setLastUpdate(prevUpdatedAt);\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            prPoller.lastBatchHandled();\n        }\n    }\n\n    /**\n     * Tests polling with padding needed and creating/modifying comments\n     */\n    @Test\n    void queryPaddingComment(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = credentials.getHostedRepository();\n            var forge = repo.forge();\n            ((TestHost) forge).setMinTimeStampUpdateInterval(Duration.ofDays(1));\n            var prPoller = new PullRequestPoller(repo, true);\n\n            // Create a PR and poll for it\n            var pr = credentials.createPullRequest(repo, \"master\", \"master\", \"Foo\");\n            var prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Poll for it again\n            prs = prPoller.updatedPullRequests();\n            assertEquals(0, prs.size());\n            assertFalse(prPoller.getCurrentQueryResult().pullRequests().isEmpty());\n            prPoller.lastBatchHandled();\n\n            // Add a new comment but make sure the updatedAt time was not updated. This should trigger an update.\n            var prevUpdatedAt = pr.updatedAt();\n            pr.addLabel(\"foo\");\n            pr.store().setLastUpdate(prevUpdatedAt);\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Add comment while keeping updatedAt unchanged. This should trigger an update.\n            prevUpdatedAt = pr.updatedAt();\n            pr.addComment(\"foo\");\n            pr.store().setLastUpdate(prevUpdatedAt);\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Update comment while keeping updatedAt unchanged. This should trigger an update.\n            prevUpdatedAt = pr.updatedAt();\n            pr.updateComment(pr.comments().get(0).id(), \"bar\");\n            pr.store().setLastUpdate(prevUpdatedAt);\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Add review while keeping updatedAt unchanged. This should trigger an update.\n            prevUpdatedAt = pr.updatedAt();\n            pr.addReview(Review.Verdict.APPROVED, \"foo\");\n            pr.store().setLastUpdate(prevUpdatedAt);\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            prPoller.lastBatchHandled();\n        }\n    }\n\n    @Test\n    void retries(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = credentials.getHostedRepository();\n            var prPoller = new PullRequestPoller(repo, false);\n\n            // Create PR\n            var pr1 = credentials.createPullRequest(repo, null, null, \"Foo\");\n            var prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Create another PR and mark the first PR for retry\n            var pr2 = credentials.createPullRequest(repo, null, null, \"Foo\");\n            prPoller.retryPullRequest(pr1);\n            prs = prPoller.updatedPullRequests();\n            assertEquals(2, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Poll again, nothing should not be returned\n            prs = prPoller.updatedPullRequests();\n            assertEquals(0, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Just mark a PR for retry\n            prPoller.retryPullRequest(pr2);\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            assertEquals(pr2.id(), prs.get(0).id());\n\n            // Call again without calling .lastBatchHandled, the retry should be included again\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            assertEquals(pr2.id(), prs.get(0).id());\n            prPoller.lastBatchHandled();\n\n            // Mark a PR for retry far in the future, it should not be included\n            prPoller.retryPullRequest(pr2, Instant.now().plus(Duration.ofDays(1)));\n            prs = prPoller.updatedPullRequests();\n            assertEquals(0, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Update PR and add it as retry, only one copy should be returned\n            pr1.addLabel(\"foo\");\n            prPoller.retryPullRequest(pr1);\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            prPoller.lastBatchHandled();\n        }\n    }\n\n    @Test\n    void quarantine(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = credentials.getHostedRepository();\n            var prPoller = new PullRequestPoller(repo, false);\n\n            // Create PR\n            var pr1 = credentials.createPullRequest(repo, null, null, \"Foo\");\n            var prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            var returnedPr1 = prs.get(0);\n            prPoller.lastBatchHandled();\n\n            // Mark it for quarantine far in the future, it should not be returned\n            prPoller.quarantinePullRequest(returnedPr1, Instant.now().plus(Duration.ofDays(1)));\n            prs = prPoller.updatedPullRequests();\n            assertEquals(0, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Touch it, it should not be returned\n            pr1.addLabel(\"foo\");\n            prs = prPoller.updatedPullRequests();\n            assertEquals(0, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Change the quarantine time to sometime in the past\n            prPoller.quarantinePullRequest(returnedPr1, Instant.now().minus(Duration.ofMinutes(1)));\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n\n            // Call again without marking as handled\n            prPoller.quarantinePullRequest(pr1, Instant.now().minus(Duration.ofMinutes(1)));\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Quarantine to sometime in the past and touch it\n            prPoller.quarantinePullRequest(returnedPr1, Instant.now().minus(Duration.ofMinutes(1)));\n            pr1.addLabel(\"bar\");\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            // Check that the returned PR object is updated with the label\n            assertTrue(prs.get(0).labelNames().contains(\"bar\"));\n\n            // Add PR both for retry and quarantine in the future, quarantine should win\n            prPoller.retryPullRequest(pr1, Instant.now().plus(Duration.ofDays(1)));\n            prPoller.quarantinePullRequest(pr1, Instant.now().plus(Duration.ofDays(1)));\n            prs = prPoller.updatedPullRequests();\n            assertEquals(0, prs.size());\n            prPoller.lastBatchHandled();\n        }\n    }\n\n    @Test\n    void positivePadding(TestInfo testInfo) throws IOException, InterruptedException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = credentials.getHostedRepository();\n            var forge = repo.forge();\n            ((TestHost) forge).setMinTimeStampUpdateInterval(Duration.ofNanos(1));\n            ((TestHost) forge).setTimeStampQueryPrecision(Duration.ofNanos(1));\n            ZonedDateTime base = ZonedDateTime.now();\n            var prPoller = new PullRequestPoller(repo, false);\n\n            // Create a PR with updatedAt set to 'base', and poll it so lastUpdatedAt is now 'base'\n            var pr1 = credentials.createPullRequest(repo, null, null, \"Foo\");\n            pr1.store().setLastUpdate(base);\n            var prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            prPoller.lastBatchHandled();\n\n            // Create two more PRs, with updatedAt just before and just after 'base'\n            var pr2 = credentials.createPullRequest(repo, null, null, \"Foo\");\n            pr2.store().setLastUpdate(base.minus(Duration.ofNanos(2)));\n            var pr3 = credentials.createPullRequest(repo, null, null, \"Foo\");\n            pr3.store().setLastUpdate(base.plus(Duration.ofNanos(2)));\n            // The negative padding is not big enough to include pr2 and pr1 has already been returned\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            assertEquals(pr3.id(), prs.get(0).id());\n            assertTrue(prPoller.getCurrentQueryResult().pullRequests().containsKey(pr1.id()));\n            prPoller.lastBatchHandled();\n\n            // Sleep a minimal amount and query again to trigger positive padding\n            Thread.sleep(1);\n            prs = prPoller.updatedPullRequests();\n            assertEquals(0, prs.size());\n            // The query should still return pr3\n            assertTrue(prPoller.getCurrentQueryResult().pullRequests().containsKey(pr3.id()));\n\n            // The same should happen again until we call lastBatchHandled()\n            prs = prPoller.updatedPullRequests();\n            assertEquals(0, prs.size());\n            // The query should still return pr3\n            assertTrue(prPoller.getCurrentQueryResult().pullRequests().containsKey(pr3.id()));\n            prPoller.lastBatchHandled();\n\n            // Now even the query should not include p3, but we should get the new pr4\n            var pr4 = credentials.createPullRequest(repo, null, null, \"Foo\");\n            pr4.store().setLastUpdate(base.plus(Duration.ofNanos(4)));\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            assertEquals(pr4.id(), prs.get(0).id());\n            assertFalse(prPoller.getCurrentQueryResult().pullRequests().containsKey(pr3.id()));\n\n            // The same should happen again until we call lastBatchHandled()\n            prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            assertEquals(pr4.id(), prs.get(0).id());\n            assertFalse(prPoller.getCurrentQueryResult().pullRequests().containsKey(pr3.id()));\n            prPoller.lastBatchHandled();\n\n            // Since we got a result, positive padding should be disabled again.\n            prs = prPoller.updatedPullRequests();\n            assertEquals(0, prs.size());\n            assertEquals(1, prPoller.getCurrentQueryResult().pullRequests().size());\n            assertTrue(prPoller.getCurrentQueryResult().pullRequests().containsKey(pr4.id()));\n\n            // The same should happen again until we call lastBatchHandled()\n            prs = prPoller.updatedPullRequests();\n            assertEquals(0, prs.size());\n            assertEquals(1, prPoller.getCurrentQueryResult().pullRequests().size());\n            assertTrue(prPoller.getCurrentQueryResult().pullRequests().containsKey(pr4.id()));\n            prPoller.lastBatchHandled();\n        }\n    }\n\n    /**\n     * Tests that an old open PR will not cause subsequent calls to return a younger\n     * but still too old closed PR.\n     */\n    @Test\n    void noResurrectClosed(TestInfo testInfo) throws IOException, InterruptedException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var repo = credentials.getHostedRepository();\n            var prPoller = new PullRequestPoller(repo, true);\n\n            var pr1 = credentials.createPullRequest(repo, null, null, \"Foo\");\n            pr1.setState(Issue.State.CLOSED);\n            pr1.store().setLastUpdate(ZonedDateTime.now().minus(Duration.ofDays(10)));\n\n            var pr2 = credentials.createPullRequest(repo, null, null, \"Foo2\");\n            pr2.store().setLastUpdate(ZonedDateTime.now().minus(Duration.ofDays(20)));\n\n            // First run should find the open PR but not the closed one, as it's older than 7 days\n            var prs = prPoller.updatedPullRequests();\n            assertEquals(1, prs.size());\n            assertEquals(pr2.id(), prs.get(0).id());\n            prPoller.lastBatchHandled();\n\n            // Second call should not find any PR\n            prs = prPoller.updatedPullRequests();\n            assertEquals(0, prs.size());\n            prPoller.lastBatchHandled();\n        }\n    }\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/PullRequestTests.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport java.util.List;\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\npublic class PullRequestTests {\n\n    @Test\n    void calculateReviewTargetRefsSimple() {\n        assertEquals(List.of(), PullRequest.calculateReviewTargetRefs(List.of(), List.of()));\n        var review1 = newReview(ZonedDateTime.now(), \"1\", \"master\");\n        assertEquals(List.of(review1), PullRequest.calculateReviewTargetRefs(List.of(review1), List.of()));\n    }\n\n    @Test\n    void calculateReviewTargetRefs2Changes() {\n        var now = ZonedDateTime.now();\n\n        var refChange1 = new ReferenceChange(\"first\", \"second\", now.minus(Duration.ofMinutes(4)));\n        var refChange2 = new ReferenceChange(\"second\", \"third\", now.minus(Duration.ofMinutes(2)));\n\n        var review1 = newReview(now.minus(Duration.ofMinutes(5)), \"1\", \"third\");\n        var review2 = newReview(now.minus(Duration.ofMinutes(3)), \"2\", \"third\");\n        var review3 = newReview(now.minus(Duration.ofMinutes(1)), \"3\", \"third\");\n\n        var reviews = PullRequest.calculateReviewTargetRefs(List.of(review1, review2, review3), List.of(refChange2, refChange1));\n\n        assertEquals(3, reviews.size());\n        assertEquals(\"first\", reviews.get(0).targetRef());\n        assertEquals(\"second\", reviews.get(1).targetRef());\n        assertEquals(\"third\", reviews.get(2).targetRef());\n    }\n\n    @Test\n    void calculateReviewTargetRefsPreIntegrationBranch() {\n        var now = ZonedDateTime.now();\n\n        var refChange1 = new ReferenceChange(\"first\", \"pr/4711\", now.minus(Duration.ofMinutes(4)));\n        var refChange2 = new ReferenceChange(\"pr/4711\", \"third\", now.minus(Duration.ofMinutes(2)));\n\n        var review1 = newReview(now.minus(Duration.ofMinutes(5)), \"1\", \"\");\n        var review2 = newReview(now.minus(Duration.ofMinutes(3)), \"2\", \"\");\n        var review3 = newReview(now.minus(Duration.ofMinutes(1)), \"3\", \"third\");\n\n        var reviews = PullRequest.calculateReviewTargetRefs(List.of(review1, review2, review3), List.of(refChange1, refChange2));\n\n        assertEquals(3, reviews.size());\n        assertEquals(\"first\", reviews.get(0).targetRef());\n        assertEquals(\"third\", reviews.get(1).targetRef());\n        assertEquals(\"third\", reviews.get(2).targetRef());\n    }\n\n    @Test\n    void calculateReviewTargetRefsPreIntegrationBranchLast() {\n        var now = ZonedDateTime.now();\n\n        var refChange1 = new ReferenceChange(\"first\", \"pr/4711\", now.minus(Duration.ofMinutes(4)));\n        var refChange2 = new ReferenceChange(\"pr/4711\", \"pr/4712\", now.minus(Duration.ofMinutes(2)));\n\n        var review1 = newReview(now.minus(Duration.ofMinutes(5)), \"1\", \"\");\n        var review2 = newReview(now.minus(Duration.ofMinutes(3)), \"2\", \"foo\");\n        var review3 = newReview(now.minus(Duration.ofMinutes(1)), \"3\", \"pr/4712\");\n\n        var reviews = PullRequest.calculateReviewTargetRefs(List.of(review1, review2, review3), List.of(refChange1, refChange2));\n\n        assertEquals(3, reviews.size());\n        assertEquals(\"first\", reviews.get(0).targetRef());\n        assertEquals(\"pr/4712\", reviews.get(1).targetRef());\n        assertEquals(\"pr/4712\", reviews.get(2).targetRef());\n    }\n\n    /**\n     * Creates a new review with just the relevant fields.\n     */\n    private Review newReview(ZonedDateTime createdAt, String id, String targetRef) {\n        return new Review(createdAt, null, Review.Verdict.APPROVED, null, id, null, targetRef);\n    }\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/PullRequestUtilsTests.java",
    "content": "/*\n * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge;\n\nimport java.io.IOException;\nimport java.util.List;\nimport java.util.Map;\nimport static org.junit.jupiter.api.Assertions.*;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.test.CheckableRepository;\nimport org.openjdk.skara.test.HostCredentials;\nimport org.openjdk.skara.test.TemporaryDirectory;\n\npublic class PullRequestUtilsTests {\n\n    @Test\n    void pullRequestLinkComment(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n             var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var repoFolder = tempFolder.path().resolve(\"repo\");\n            var localRepo = CheckableRepository.init(repoFolder, repo.repositoryType());\n            credentials.commitLock(localRepo);\n            localRepo.pushAll(repo.authenticatedUrl());\n\n            var issueProject = credentials.getIssueProject();\n            var issue = issueProject.createIssue(\"This is an issue\", List.of(\"Indeed\"), Map.of(\"issuetype\", JSON.of(\"Enhancement\")));\n            var editHash = CheckableRepository.appendAndCommit(localRepo, \"Another line\", issue.id() + \": Fix that issue\");\n            localRepo.push(editHash, repo.authenticatedUrl(), \"master\");\n            var pr1 = credentials.createPullRequest(repo, \"master\", \"master\", issue.id() + \": Fix that issue\");\n\n            {\n                assertEquals(0, issue.comments().size());\n\n                PullRequestUtils.postPullRequestLinkComment(issue, pr1);\n                assertEquals(1, issue.comments().size());\n\n                var prLinks = PullRequestUtils.pullRequestCommentLink(issue);\n                assertEquals(pr1.webUrl(), prLinks.get(0));\n\n                PullRequestUtils.removePullRequestLinkComment(issue, pr1);\n                assertEquals(0, issue.comments().size());\n            }\n            {\n                var pr2 = credentials.createPullRequest(repo, \"master\", \"master\", issue.id() + \": Fix that issue\");\n\n                PullRequestUtils.postPullRequestLinkComment(issue, pr1);\n                PullRequestUtils.postPullRequestLinkComment(issue, pr2);\n                assertEquals(2, issue.comments().size());\n\n                var prLinks = PullRequestUtils.pullRequestCommentLink(issue);\n                assertEquals(pr1.webUrl(), prLinks.get(0));\n                assertEquals(pr2.webUrl(), prLinks.get(1));\n\n                PullRequestUtils.removePullRequestLinkComment(issue, pr1);\n                assertEquals(1, issue.comments().size());\n\n                prLinks = PullRequestUtils.pullRequestCommentLink(issue);\n                assertEquals(pr2.webUrl(), prLinks.get(0));\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/bitbucket/BitbucketForgeFactoryTests.java",
    "content": "/*\n * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.bitbucket;\n\nimport java.net.URI;\nimport java.util.Optional;\n\nimport org.openjdk.skara.json.JSON;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass BitbucketForgeFactoryTests {\n    @Test\n    void nameConfiguration() {\n        var conf = JSON.object().put(\"name\", \"foo\");\n        var factory = new BitbucketForgeFactory();\n        var forge = factory.create(URI.create(\"http://www.example.com\"), null, conf);\n\n        assertEquals(\"foo\", forge.name());\n    }\n\n    @Test\n    void prTemplateConfiguration() {\n        var conf = JSON.object().put(\"prTemplate\", JSON.array().add(\"\").add(\"second\").add(\"third\"));\n        var factory = new BitbucketForgeFactory();\n        var forge = factory.create(URI.create(\"http://www.example.com\"), null, conf);\n\n        assertEquals(Optional.of(\"\\nsecond\\nthird\"), forge.defaultPullRequestTemplate());\n    }\n\n    @Test\n    void defaultConfiguration() {\n        var factory = new BitbucketForgeFactory();\n        var bb = (BitbucketHost) factory.create(URI.create(\"http://www.example.com\"), null, null);\n\n        assertEquals(\"Bitbucket\", bb.name());\n        assertEquals(Optional.empty(), bb.defaultPullRequestTemplate());\n    }\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/github/GitHubApplicationTests.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.github;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.time.Duration;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class GitHubApplicationTests {\n\n    @Test\n    public void tokenSetSimple() {\n        Token t = new Token(() -> \"a\", Duration.ofHours(1));\n        assertEquals(\"a\", t.toString());\n    }\n\n    private final String[] sequence = {\"a\", \"b\", \"c\"};\n    private int sequenceIndex = 0;\n    private String sequenceGenerator() {\n        return sequence[sequenceIndex++];\n    }\n\n    @Test\n    public void tokenCache() {\n        sequenceIndex = 0;\n        Token t = new Token(this::sequenceGenerator, Duration.ofHours(1));\n        assertEquals(\"a\", t.toString());\n        assertEquals(\"a\", t.toString());\n    }\n\n    @Test\n    public void tokenExpiration() {\n        sequenceIndex = 0;\n        Token t = new Token(this::sequenceGenerator, Duration.ZERO);\n        assertEquals(\"a\", t.toString());\n        assertEquals(\"b\", t.toString());\n    }\n\n    private String badGenerator() throws Token.GeneratorError {\n        throw new Token.GeneratorError(\"error\");\n    }\n\n    @Test\n    public void tokenGeneratorError() {\n        Token t = new Token(this::badGenerator, Duration.ZERO);\n        assertThrows(GitHubApplicationError.class, () -> t.toString());\n    }\n\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/github/GitHubForgeFactoryTests.java",
    "content": "/*\n * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.github;\n\nimport java.net.URI;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\n\nimport org.openjdk.skara.json.JSON;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass GitHubForgeFactoryTests {\n    @Test\n    void webUrlConfiguration() {\n        var conf = JSON.object()\n            .put(\"weburl\", JSON.object()\n                .put(\"pattern\", \"^(http://www.example.com)/test/(.*)$\")\n                .put(\"replacement\", \"$1/another/$2\")\n        );\n        var factory = new GitHubForgeFactory();\n        var gh = (GitHubHost) factory.create(URI.create(\"http://www.example.com\"), null, conf);\n\n        assertEquals(URI.create(\"http://www.example.com/another/hello\"), gh.getWebURI(\"/test/hello\"));\n    }\n\n    @Test\n    void altWebUrlConfiguration() {\n        var conf = JSON.object()\n            .put(\"weburl\", JSON.object()\n                .put(\"pattern\", \"^(http://www.example.com)/test/(.*)$\")\n                .put(\"replacement\", \"$1/another/$2\")\n                .put(\"altreplacements\", JSON.array().add(\"http://localhost/$2\"))\n        );\n        var factory = new GitHubForgeFactory();\n        var gh = (GitHubHost) factory.create(URI.create(\"http://www.example.com\"), null, conf);\n\n        assertEquals(List.of(URI.create(\"http://www.example.com/another/hello\"),\n                             URI.create(\"http://localhost/hello\")),\n                     gh.getAllWebURIs(\"/test/hello\"));\n    }\n\n    @Test\n    void offlineConfiguration() {\n        var conf = JSON.object().put(\"offline\", true);\n        var factory = new GitHubForgeFactory();\n        var gh = (GitHubHost) factory.create(URI.create(\"https://github.test\"), null, conf);\n\n        assertTrue(gh.isOffline());\n    }\n\n    @Test\n    void orgsConfiguration() {\n        var conf = JSON.object().put(\"orgs\", JSON.array().add(\"foo\").add(\"bar\"));\n        var factory = new GitHubForgeFactory();\n        var gh = (GitHubHost) factory.create(URI.create(\"https://github.test\"), null, conf);\n        assertEquals(Set.of(\"foo\", \"bar\"), gh.organizations());\n    }\n\n    @Test\n    void prTemplateConfiguration() {\n        var conf = JSON.object().put(\"prTemplate\", JSON.array().add(\"\").add(\"second\").add(\"third\"));\n        var factory = new GitHubForgeFactory();\n        var forge = factory.create(URI.create(\"https://github.test\"), null, conf);\n        assertEquals(Optional.of(\"\\nsecond\\nthird\"), forge.defaultPullRequestTemplate());\n    }\n\n    @Test\n    void defaultConfiguration() {\n        var factory = new GitHubForgeFactory();\n        var gh = (GitHubHost) factory.create(URI.create(\"http://github.test\"), null, null);\n\n        assertEquals(Set.of(), gh.organizations());\n        assertFalse(gh.isOffline());\n        assertEquals(URI.create(\"http://github.test/hello\"), gh.getWebURI(\"/hello\"));\n        assertEquals(List.of(URI.create(\"http://github.test/hello\")), gh.getAllWebURIs(\"/hello\"));\n        assertEquals(Optional.empty(), gh.defaultPullRequestTemplate());\n    }\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/github/GitHubHostTests.java",
    "content": "/*\n * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.github;\n\nimport java.util.List;\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.test.TemporaryDirectory;\n\nimport java.io.IOException;\nimport java.net.*;\nimport java.util.regex.Pattern;\nimport java.util.Set;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass GitHubHostTests {\n    @Test\n    void webUriPatternReplacement() throws URISyntaxException {\n        var host = new GitHubHost(URIBuilder.base(\"http://www.example.com\").build(),\n                Pattern.compile(\"^(http://www.example.com)/test/(.*)$\"), \"$1/another/$2\",\n                List.of(), Set.of(), null, false);\n        assertEquals(new URI(\"http://www.example.com/another/hello\"), host.getWebURI(\"/test/hello\"));\n    }\n\n    @Test\n    void nonTransformedWebUrl() throws URISyntaxException {\n        var host = new GitHubHost(URIBuilder.base(\"http://www.example.com\").build(),\n                Pattern.compile(\"^(http://www.example.com)/test/(.*)$\"), \"$1/another/$2\",\n                List.of(), Set.of(), null, false);\n        assertEquals(new URI(\"http://www.example.com/another/hello\"), host.getWebURI(\"/test/hello\"));\n        assertEquals(new URI(\"http://www.example.com/test/hello\"), host.getWebURI(\"/test/hello\", false));\n    }\n\n    @Test\n    void webAltUriPatternReplacement() throws URISyntaxException {\n        var host = new GitHubHost(URIBuilder.base(\"http://www.example.com\").build(),\n                Pattern.compile(\"^(http://www.example.com)/test/(.*)$\"), \"$1/another/$2\",\n                List.of(\"http://localhost/$2\"), Set.of(), null, false);\n        assertEquals(new URI(\"http://www.example.com/another/hello\"), host.getWebURI(\"/test/hello\"));\n        assertEquals(List.of(\n                        new URI(\"http://www.example.com/another/hello\"),\n                        new URI(\"http://localhost/hello\")\n                ),\n                host.getAllWebURIs(\"/test/hello\"));\n    }\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/github/GitHubIntegrationTests.java",
    "content": "/*\n * Copyright (c) 2022, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.github;\n\nimport java.time.Duration;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.forge.Forge;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.forge.MemberState;\nimport org.openjdk.skara.forge.PullRequest;\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.proxy.HttpProxy;\nimport org.openjdk.skara.test.TestProperties;\nimport org.openjdk.skara.test.EnabledIfTestProperties;\nimport org.openjdk.skara.vcs.Branch;\nimport org.openjdk.skara.vcs.Diff;\nimport org.openjdk.skara.vcs.DiffComparator;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.io.IOException;\nimport java.util.List;\nimport java.util.Optional;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.junit.jupiter.api.Assumptions.assumeTrue;\n\nclass GitHubIntegrationTests {\n    private static final String GITHUB_REST_URI = \"https://github.com\";\n    private static Forge githubHost;\n    private static TestProperties props;\n\n    @BeforeAll\n    static void beforeAll() throws IOException {\n        props = TestProperties.load();\n        if (props.contains(\"github.user\", \"github.pat\")) {\n            HttpProxy.setup();\n            // Here use the OAuth2 token. To use a GitHub App, please see ManualForgeTests#gitHubLabels.\n            var username = props.get(\"github.user\");\n            var token = props.get(\"github.pat\");\n            var credential = new Credential(username, token);\n            var uri = URIBuilder.base(GITHUB_REST_URI).build();\n            githubHost = new GitHubForgeFactory().create(uri, credential, null);\n        }\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\"})\n    void testDiffEqual() throws IOException {\n        var githubRepoOpt = githubHost.repository(\"openjdk/jfx\");\n        assumeTrue(githubRepoOpt.isPresent());\n        var githubRepo = githubRepoOpt.get();\n\n        // Test the files number of a PR\n        var prDiffLittle = testDiffOfPullRequest(githubRepo, \"756\", 2);\n        var prDiffMiddle = testDiffOfPullRequest(githubRepo, \"764\", 105);\n        var prDiffLarge = testDiffOfPullRequest(githubRepo, \"723\", 3000); // Only 3000 files return\n\n        // Test the file number of a commit\n        var commitDiffLittle = testDiffOfCommit(githubRepo, new Hash(\"eb7fa5dd1c0911bca15576060691d884d29895a1\"), 2);\n        var commitDiffMiddle = testDiffOfCommit(githubRepo, new Hash(\"b0f2521219efc1b0d0c45088736d5105712bc2c9\"), 105);\n        var commitDiffLarge = testDiffOfCommit(githubRepo, new Hash(\"6f28d912024495278c4c35ab054bc2aab480b3e4\"), 3000); // Only 3000 files return\n\n        // Test whether the diff is equal.\n        assertTrue(DiffComparator.areFuzzyEqual(commitDiffLittle, prDiffLittle));\n        assertTrue(DiffComparator.areFuzzyEqual(commitDiffMiddle, prDiffMiddle));\n        assertTrue(DiffComparator.areFuzzyEqual(commitDiffLarge, prDiffLarge));\n    }\n\n    Diff testDiffOfPullRequest(HostedRepository githubRepo, String prId, int expectedPatchesSize) {\n        var pr = githubRepo.pullRequest(prId);\n        var diff = pr.diff();\n        assertEquals(expectedPatchesSize, diff.patches().size());\n        return diff;\n    }\n\n    Diff testDiffOfCommit(HostedRepository githubRepo, Hash hash, int expectedPatchesSize) {\n        var commit = githubRepo.commit(hash);\n        assumeTrue(commit.isPresent());\n        assertEquals(1, commit.get().parentDiffs().size());\n        assertEquals(expectedPatchesSize, commit.get().parentDiffs().get(0).patches().size());\n        return commit.get().parentDiffs().get(0);\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\"})\n    void testLastForcePushTime() {\n        var githubRepoOpt = githubHost.repository(\"openjdk/playground\");\n        assumeTrue(githubRepoOpt.isPresent());\n        var githubRepo = githubRepoOpt.get();\n        var pr = githubRepo.pullRequest(\"96\");\n        var lastForcePushTime = pr.lastForcePushTime();\n        assertEquals(\"2022-05-29T10:32:43Z\", lastForcePushTime.get().toString());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\"})\n    void testFindIntegratedCommitHash() {\n        var playgroundRepoOpt = githubHost.repository(\"openjdk/playground\");\n        assumeTrue(playgroundRepoOpt.isPresent());\n        var playgroundRepo = playgroundRepoOpt.get();\n        var playgroundPr = playgroundRepo.pullRequest(\"96\");\n        var playgroundHashOpt = playgroundPr.findIntegratedCommitHash();\n        assertTrue(playgroundHashOpt.isEmpty());\n        // `43336822` is the id of the `openjdk` bot(a GitHub App).\n        playgroundHashOpt = playgroundPr.findIntegratedCommitHash(List.of(\"43336822\"));\n        assertTrue(playgroundHashOpt.isEmpty());\n\n        var jdkRepoOpt = githubHost.repository(\"openjdk/jdk\");\n        assumeTrue(jdkRepoOpt.isPresent());\n        var jdkRepo = jdkRepoOpt.get();\n        var jdkPr = jdkRepo.pullRequest(\"8648\");\n        var jdkHashOpt = jdkPr.findIntegratedCommitHash();\n        assertTrue(jdkHashOpt.isEmpty());\n        // `43336822` is the id of the `openjdk` bot(a GitHub App).\n        jdkHashOpt = jdkPr.findIntegratedCommitHash(List.of(\"43336822\"));\n        assertTrue(jdkHashOpt.isPresent());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\"})\n    void testOversizeComment() {\n        var testRepoOpt = githubHost.repository(\"openjdk/playground\");\n        assumeTrue(testRepoOpt.isPresent());\n        var testRepo = testRepoOpt.get();\n        var testPr = testRepo.pullRequest(\"99\");\n\n        // Test add comment\n        Comment comment = testPr.addComment(\"1\".repeat(1_000_000));\n        assertTrue(comment.body().contains(\"...\"));\n        assertTrue(comment.body().contains(\"1\"));\n\n        // Test update comment\n        Comment updateComment = testPr.updateComment(comment.id(), \"2\".repeat(2_000_000));\n        assertTrue(updateComment.body().contains(\"...\"));\n        assertTrue(updateComment.body().contains(\"2\"));\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\"})\n    void testForcePushTimeWhenPRInDraft() {\n        var testRepoOpt = githubHost.repository(\"openjdk/playground\");\n        assumeTrue(testRepoOpt.isPresent());\n        var testRepo = testRepoOpt.get();\n        var testPr = (GitHubPullRequest) testRepo.pullRequest(\"107\");\n\n        // Won't get the force push time when if the force push is during draft period\n        assertEquals(Optional.empty(), testPr.lastForcePushTime());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\",\n                              \"github.repository\", \"github.repository.branch\"})\n    void fileContentsNonExisting() {\n        var gitHubRepo = githubHost.repository(props.get(\"github.repository\")).orElseThrow();\n        var branch = new Branch(props.get(\"github.repository.branch\"));\n        var fileName = \"testfile-that-does-not-exist.txt\";\n        var returnedContents = gitHubRepo.fileContents(fileName, branch.name());\n        assertTrue(returnedContents.isEmpty());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\",\n                              \"github.repository\", \"github.repository.branch\"})\n    void writeFileContents() {\n        var gitHubRepo = githubHost.repository(props.get(\"github.repository\")).orElseThrow();\n        var branch = new Branch(props.get(\"github.repository.branch\"));\n\n        var fileName = \"testfile.txt\";\n\n        // Create new file\n        {\n            var fileContent = \"File content\";\n            gitHubRepo.writeFileContents(fileName, fileContent, branch,\n                    \"First commit message\", \"Duke\", \"duke@openjdk.org\", false);\n            var returnedContents = gitHubRepo.fileContents(fileName, branch.name());\n            assertEquals(fileContent, returnedContents.orElseThrow());\n        }\n\n        // Update file\n        {\n            var fileContent = \"New file content\";\n            gitHubRepo.writeFileContents(fileName, fileContent, branch,\n                    \"Second commit message\", \"Duke\", \"duke@openjdk.org\", true);\n            var returnedContents = gitHubRepo.fileContents(fileName, branch.name());\n            assertEquals(fileContent, returnedContents.orElseThrow());\n        }\n\n        // Make the file huge\n        {\n            var fileContent = \"a\".repeat(1024 * 1024 * 10);\n            gitHubRepo.writeFileContents(fileName, fileContent, branch,\n                    \"Third commit message\", \"Duke\", \"duke@openjdk.org\", true);\n            var returnedContents = gitHubRepo.fileContents(fileName, branch.name());\n            assertTrue(returnedContents.isPresent());\n            assertEquals(fileContent.length(), returnedContents.get().length());\n            assertTrue(fileContent.equals(returnedContents.get()),\n                    \"Diff for huge file contents, printing first 50 chars of each '\"\n                    + fileContent.substring(0, 50) + \"' '\" + returnedContents.get().substring(0, 50) + \"'\");\n        }\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\"})\n    void testLastMarkedAsDraftTime() {\n        var githubRepoOpt = githubHost.repository(\"openjdk/playground\");\n        assumeTrue(githubRepoOpt.isPresent());\n        var githubRepo = githubRepoOpt.get();\n        var pr = githubRepo.pullRequest(\"129\");\n        var lastMarkedAsDraftTime = pr.lastMarkedAsDraftTime();\n        assertEquals(\"2023-02-11T11:51:12Z\", lastMarkedAsDraftTime.get().toString());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\"})\n    void testClosedBy() {\n        var githubRepoOpt = githubHost.repository(\"openjdk/playground\");\n        assumeTrue(githubRepoOpt.isPresent());\n        var githubRepo = githubRepoOpt.get();\n        var pr = githubRepo.pullRequest(\"96\");\n        var user = pr.closedBy();\n        assertEquals(\"lgxbslgx\", user.get().username());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\"})\n    void testGeneratingUrl() {\n        var username = props.get(\"github.user\");\n        var token = props.get(\"github.pat\");\n        var credential = new Credential(username, token);\n        var uri = URIBuilder.base(GITHUB_REST_URI).build();\n        var configuration = JSON.object().put(\"weburl\", JSON.object().put(\"pattern\", \"^https://github.com/openjdk/(.*)$\").put(\"replacement\", \"https://git.openjdk.org/$1\"));\n        var githubHost = new GitHubForgeFactory().create(uri, credential, configuration);\n\n        var githubRepoOpt = githubHost.repository(\"openjdk/playground\");\n        assumeTrue(githubRepoOpt.isPresent());\n        var githubRepo = githubRepoOpt.get();\n        var pr = githubRepo.pullRequest(\"129\");\n\n        var labelComment = pr.comments().stream()\n                .filter(comment -> comment.body().contains(\"The following label will be automatically applied to this pull request:\"))\n                .findFirst()\n                .get();\n        assertEquals(\"https://git.openjdk.org/playground/pull/129#issuecomment-1426703897\", pr.commentUrl(labelComment).toString());\n\n        var reviewComment = pr.reviewComments().get(0);\n        assertEquals(\"https://git.openjdk.org/playground/pull/129#discussion_r1108931186\", pr.reviewCommentUrl(reviewComment).toString());\n\n        var review = pr.reviews().get(0);\n        assertEquals(\"https://git.openjdk.org/playground/pull/129#pullrequestreview-1302142525\", pr.reviewUrl(review).toString());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\", \"github.repository\"})\n    void testDeleteDeployKey() {\n        var githubRepoOpt = githubHost.repository(props.get(\"github.repository\"));\n        assumeTrue(githubRepoOpt.isPresent());\n        var githubRepo = githubRepoOpt.get();\n        githubRepo.deleteDeployKeys(Duration.ofHours(24));\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\",\n                              \"github.repository\", \"github.repository.branch\", \"github.user2\"})\n    void restrictPushAccess() {\n        var gitHubRepo = githubHost.repository(props.get(\"github.repository\")).orElseThrow();\n        var branch = new Branch(props.get(\"github.repository.branch\"));\n        var user = githubHost.user(props.get(\"github.user2\")).orElseThrow();\n        gitHubRepo.restrictPushAccess(branch, user);\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\", \"github.repository\"})\n    void testDeployKeyTitles() {\n        var githubRepoOpt = githubHost.repository(props.get(\"github.repository\"));\n        assumeTrue(githubRepoOpt.isPresent());\n        var githubRepo = githubRepoOpt.get();\n        var expiredDeployKeys = githubRepo.deployKeyTitles(Duration.ofMinutes(5));\n        assertTrue(expiredDeployKeys.contains(\"Test1\"));\n        assertTrue(expiredDeployKeys.contains(\"Test2\"));\n    }\n\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\",\n                              \"github.repository\", \"github.prId\", \"github.commitHash\"})\n    void testBackportCleanIgnoreCopyRight() {\n        var gitHubRepo = githubHost.repository(props.get(\"github.repository\")).orElseThrow();\n\n        var pr = gitHubRepo.pullRequest(props.get(\"github.prId\"));\n        var hash = new Hash(props.get(\"github.commitHash\"));\n        var repoName = pr.repository().forge().search(hash, true);\n        assertTrue(repoName.isPresent());\n        var repository = pr.repository().forge().repository(repoName.get());\n        assertTrue(repository.isPresent());\n        var commit = repository.get().commit(hash, true);\n        var backportDiff = commit.orElseThrow().parentDiffs().get(0);\n        var prDiff = pr.diff();\n        var isClean = DiffComparator.areFuzzyEqual(backportDiff, prDiff);\n        assertTrue(isClean);\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\",\n                              \"github.repository\", \"github.repository.branch\"})\n    void testDefaultBranchName() {\n        var gitHubRepo = githubHost.repository(props.get(\"github.repository\")).orElseThrow();\n        assertEquals(props.get(\"github.repository.branch\"), gitHubRepo.defaultBranchName());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\", \"github.repository\"})\n    void testCollaborators() {\n        var gitHubRepo = githubHost.repository(props.get(\"github.repository\")).orElseThrow();\n        var collaborators = gitHubRepo.collaborators();\n        assertNotNull(collaborators);\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\"})\n    void testGetUser() {\n        var userName = props.get(\"github.user\");\n        var userByName = githubHost.user(userName).orElseThrow();\n        var userById = githubHost.userById(userByName.id()).orElseThrow();\n        assertEquals(userByName, userById);\n    }\n\n    /**\n     * Expects\n     * github.group: Name of GitHub organization with at least one member\n     */\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\", \"github.group\"})\n    void testGroupMembers() {\n        var groupName = props.get(\"github.group\");\n        var membersList = githubHost.groupMembers(groupName);\n        assertNotNull(membersList);\n        assertNotEquals(0, membersList.size());\n    }\n\n    /**\n     * Expects:\n     * github.group: Name of GitHub organization\n     * github.group.member: Name of user which is a member of the organization\n     * github.group.notmember: Name of user which is not a member of the organization\n     */\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\",\n                              \"github.group\", \"github.group.member\", \"github.group.notmember\"})\n    void testGroupMemberState() {\n        var groupName = props.get(\"github.group\");\n        var memberName = props.get(\"github.group.member\");\n        var notMemberName = props.get(\"github.group.notmember\");\n        var member = githubHost.user(memberName).orElseThrow();\n        var notMember = githubHost.user(notMemberName).orElseThrow();\n        assertEquals(MemberState.ACTIVE, githubHost.groupMemberState(groupName, member));\n        assertEquals(MemberState.MISSING, githubHost.groupMemberState(groupName, notMember));\n    }\n\n    /**\n     * Expects:\n     * github.group: Name of GitHub organization\n     * github.group.user: Name of user which may or may not be a member already,\n     *                    but cannot be an owner\n     */\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\",\n                              \"github.group\", \"github.group.user\"})\n    void testAddGroupMember() {\n        var groupName = props.get(\"github.group\");\n        var userName = props.get(\"github.group.user\");\n        var user = githubHost.user(userName).orElseThrow();\n        githubHost.addGroupMember(groupName, user);\n        assertNotEquals(MemberState.MISSING, githubHost.groupMemberState(groupName, user));\n    }\n\n    /**\n     * Expects:\n     * github.collaborators.repository: Github repository where user has admin access\n     * github.collaborators.user: User not currently a collaborator in repository\n     */\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\",\n                              \"github.collaborators.repository\", \"github.collaborators.user\"})\n    void addRemoveCollaborator() {\n        var gitHubRepo = githubHost.repository(props.get(\"github.collaborators.repository\")).orElseThrow();\n        var userName = props.get(\"github.collaborators.user\");\n        var user = gitHubRepo.forge().user(userName).orElseThrow();\n        gitHubRepo.addCollaborator(user, false);\n        // On Github, the user has to accept an invitation before becoming a collaborator\n        // so we cannot verify automatically here.\n        gitHubRepo.removeCollaborator(user);\n    }\n\n    /**\n     * Expects:\n     * github.repository: Github repository where pull requests can be made, e.g. playground\n     * github.repository.branch: Branch in github.repository to create pull request from\n     */\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\", \"github.repository\",\n            \"github.repository.branch\"})\n    void createPullRequestSameRepo() {\n        var gitHubRepo = githubHost.repository((props.get(\"github.repository\"))).orElseThrow();\n        var sourceBranch = props.get(\"github.repository.branch\");\n        var pr = gitHubRepo.createPullRequest(gitHubRepo, \"master\", sourceBranch,\n                \"Skara test PR from same repo\", List.of(\"Skara test PR from same repo\"));\n        assertEquals(gitHubRepo.name(), pr.repository().name());\n        assertEquals(gitHubRepo.group(), pr.repository().group());\n        assertEquals(\"master\", pr.targetRef());\n        assertEquals(gitHubRepo.name(), pr.sourceRepository().orElseThrow().name());\n        assertEquals(gitHubRepo.group(), pr.sourceRepository().orElseThrow().group());\n        pr.setState(PullRequest.State.CLOSED);\n    }\n\n    /**\n     * Expects:\n     * github.repository: Github repository where pull requests can be made, e.g. playground\n     * github.user: User with a fork of github.repository with permission to create PR in github.respository\n     * github.user.fork.branch: Name of branch in user fork to create pull request from\n     */\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\", \"github.repository\",\n            \"github.user.fork.branch\"})\n    void createPullRequestUserFork() {\n        var gitHubRepo = githubHost.repository((props.get(\"github.repository\"))).orElseThrow();\n        var sourceBranch = props.get(\"github.user.fork.branch\");\n        var userName = props.get(\"github.user\");\n        String name = gitHubRepo.name().split(\"/\")[1];\n        var sourceRepo = githubHost.repository(userName + \"/\" + name).orElseThrow();\n        var pr = sourceRepo.createPullRequest(gitHubRepo, \"master\", sourceBranch,\n                \"Skara test PR from user fork\", List.of(\"Skara test PR from user fork\"));\n        assertEquals(gitHubRepo.name(), pr.repository().name());\n        assertEquals(gitHubRepo.group(), pr.repository().group());\n        assertEquals(\"master\", pr.targetRef());\n        assertEquals(sourceRepo.name(), pr.sourceRepository().orElseThrow().name());\n        assertEquals(sourceRepo.group(), pr.sourceRepository().orElseThrow().group());\n        pr.setState(PullRequest.State.CLOSED);\n    }\n\n    /**\n     * Expects:\n     * github.repository: Github repository where pull requests can be made, e.g. playground\n     * github.user: User with a fork of github.repository with permission to create PR in github.respository\n     * github.group: Group with fork of github.repository\n     * github.group.fork.branch: Name of branch in group fork to create pull request from\n     */\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\", \"github.repository\",\n            \"github.group\", \"github.group.fork.branch\"})\n    void createPullRequestGroupFork() {\n        var gitHubRepo = githubHost.repository((props.get(\"github.repository\"))).orElseThrow();\n        var groupName = props.get(\"github.group\");\n        var sourceBranch = props.get(\"github.group.fork.branch\");\n        String name = gitHubRepo.name().split(\"/\")[1];\n        var sourceRepo = githubHost.repository(groupName + \"/\" + name).orElseThrow();\n        var pr = sourceRepo.createPullRequest(gitHubRepo, \"master\", sourceBranch,\n                \"Skara test PR from group fork\", List.of(\"Skara test PR from group fork\"));\n        assertEquals(gitHubRepo.name(), pr.repository().name());\n        assertEquals(gitHubRepo.group(), pr.repository().group());\n        assertEquals(\"master\", pr.targetRef());\n        assertEquals(sourceRepo.name(), pr.sourceRepository().orElseThrow().name());\n        assertEquals(sourceRepo.group(), pr.sourceRepository().orElseThrow().group());\n        pr.setState(PullRequest.State.CLOSED);\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\", \"github.stale.repository\", \"github.stale.commitHash\"})\n    void testRedirectLink() {\n        var githubRepoOpt = githubHost.repository(props.get(\"github.stale.repository\"));\n        var checks = githubRepoOpt.get().allChecks(new Hash(props.get(\"github.stale.commitHash\")));\n        assertFalse(checks.isEmpty());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\", \"github.repository\", \"github.prId\", \"github.repository.commitHash\"})\n    void testDiffComplete() {\n        var githubRepoOpt = githubHost.repository(props.get(\"github.repository\"));\n        assumeTrue(githubRepoOpt.isPresent());\n        var githubRepo = githubRepoOpt.get();\n        var commit = githubRepo.commit(new Hash(props.get(\"github.repository.commitHash\")), true);\n        var diff = commit.get().parentDiffs().get(0);\n        assertFalse(diff.complete());\n\n        var pr = githubRepo.pullRequest(props.get(\"github.prId\"));\n        var prDiff = pr.diff();\n        assertFalse(prDiff.complete());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"github.user\", \"github.pat\", \"github.repository\", \"github.prId\"})\n    void testLastCommitTime() {\n        var githubRepoOpt = githubHost.repository(props.get(\"github.repository\"));\n        assumeTrue(githubRepoOpt.isPresent());\n        var githubRepo = githubRepoOpt.get();\n\n        var pr = githubRepo.pullRequest(props.get(\"github.prId\"));\n        var lastTouchedTime = pr.lastTouchedTime();\n    }\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/gitlab/GitLabForgeFactoryTests.java",
    "content": "/*\n * Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.gitlab;\n\nimport java.net.URI;\nimport java.util.List;\nimport java.util.Optional;\n\nimport org.openjdk.skara.json.JSON;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass GitLabForgeFactoryTests {\n    @Test\n    void nameConfiguration() {\n        var conf = JSON.object().put(\"name\", \"foo\");\n        var factory = new GitLabForgeFactory();\n        var forge = factory.create(URI.create(\"http://www.example.com\"), null, conf);\n\n        assertEquals(\"foo\", forge.name());\n    }\n\n    @Test\n    void groupsConfiguration() {\n        var conf = JSON.object().put(\"groups\", JSON.array().add(\"foo\").add(\"bar\"));\n        var factory = new GitLabForgeFactory();\n        var gl = (GitLabHost) factory.create(URI.create(\"http://www.example.com\"), null, conf);\n\n        assertEquals(List.of(\"foo\", \"bar\"), gl.groups());\n    }\n\n    @Test\n    void prTemplateConfiguration() {\n        var conf = JSON.object().put(\"prTemplate\", JSON.array().add(\"\").add(\"second\").add(\"third\"));\n        var factory = new GitLabForgeFactory();\n        var forge = factory.create(URI.create(\"http://www.example.com\"), null, conf);\n\n        assertEquals(Optional.of(\"\\nsecond\\nthird\"), forge.defaultPullRequestTemplate());\n    }\n\n    @Test\n    void defaultConfiguration() {\n        var factory = new GitLabForgeFactory();\n        var gl = (GitLabHost) factory.create(URI.create(\"http://www.example.com\"), null, null);\n\n        assertEquals(\"GitLab\", gl.name());\n        assertEquals(List.of(), gl.groups());\n        assertEquals(Optional.empty(), gl.defaultPullRequestTemplate());\n    }\n}\n"
  },
  {
    "path": "forge/src/test/java/org/openjdk/skara/forge/gitlab/GitLabIntegrationTests.java",
    "content": "/*\n * Copyright (c) 2022, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.forge.gitlab;\n\nimport java.nio.file.Path;\nimport java.time.ZonedDateTime;\nimport java.util.Arrays;\nimport java.util.Set;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestProperties;\nimport org.openjdk.skara.test.EnabledIfTestProperties;\nimport org.openjdk.skara.vcs.Branch;\nimport org.openjdk.skara.vcs.DiffComparator;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.util.List;\nimport org.openjdk.skara.vcs.git.GitRepository;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass GitLabIntegrationTests {\n    private static TestProperties props;\n    private static GitLabHost gitLabHost;\n\n    @BeforeAll\n    static void beforeAll() throws IOException {\n        props = TestProperties.load();\n        if (props.contains(\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\")) {\n            var username = props.get(\"gitlab.user\");\n            var token = props.get(\"gitlab.pat\");\n            var credential = new Credential(username, token);\n            var uri = URIBuilder.base(props.get(\"gitlab.uri\")).build();\n            gitLabHost = new GitLabHost(\"gitlab\", uri, false, credential, Arrays.asList(props.get(\"gitlab.group\").split(\",\")), null);\n        }\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"gitlab.repository\", \"gitlab.merge.request.id\", \"gitlab.review.hash\"})\n    void testReviews() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n        var gitLabMergeRequest = gitLabRepo.pullRequest(props.get(\"gitlab.merge.request.id\"));\n\n        var reviewList = gitLabMergeRequest.reviews();\n        var actualHash = reviewList.get(0).hash().orElse(new Hash(\"\"));\n        assertEquals(props.get(\"gitlab.review.hash\"), actualHash.hex());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"gitlab.repository\", \"gitlab.merge.request.id\",\n                              \"gitlab.version.hash\", \"gitlab.version.url\",\n                              \"gitlab.nonversion.hash\", \"gitlab.nonversion.url\"})\n    void testFilesUrl() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n        var gitLabMergeRequest = gitLabRepo.pullRequest(props.get(\"gitlab.merge.request.id\"));\n\n        // Test a version hash\n        var versionUrl = gitLabMergeRequest.filesUrl(new Hash(props.get(\"gitlab.version.hash\")));\n        assertEquals(props.get(\"gitlab.version.url\"), versionUrl.toString());\n\n        // Test a non-version hash\n        var nonVersionUrl = gitLabMergeRequest.filesUrl(new Hash(props.get(\"gitlab.nonversion.hash\")));\n        assertEquals(props.get(\"gitlab.nonversion.url\"), nonVersionUrl.toString());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"gitlab.repository\", \"gitlab.merge.request.id\"})\n    void testLabels() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n        var gitLabMergeRequest = gitLabRepo.pullRequest(props.get(\"gitlab.merge.request.id\"));\n\n        // Get the labels\n        var labels = gitLabMergeRequest.labelNames();\n        assertEquals(1, labels.size());\n        assertEquals(\"test\", labels.get(0));\n\n        // Add a label\n        gitLabMergeRequest.addLabel(\"test1\");\n        labels = gitLabMergeRequest.labelNames();\n        assertEquals(2, labels.size());\n        assertEquals(\"test\", labels.get(0));\n        assertEquals(\"test1\", labels.get(1));\n\n        // Remove a label\n        gitLabMergeRequest.removeLabel(\"test1\");\n        labels = gitLabMergeRequest.labelNames();\n        assertEquals(1, labels.size());\n        assertEquals(\"test\", labels.get(0));\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"gitlab.repository\", \"gitlab.merge.request.id\"})\n    void testOversizeComment() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n        var gitLabMergeRequest = gitLabRepo.pullRequest(props.get(\"gitlab.merge.request.id\"));\n\n        // Test add comment\n        Comment comment = gitLabMergeRequest.addComment(\"1\".repeat(1_000_000));\n        assertTrue(comment.body().contains(\"...\"));\n        assertTrue(comment.body().contains(\"1\"));\n\n        // Test update comment\n        Comment updateComment = gitLabMergeRequest.updateComment(comment.id(), \"2\".repeat(2_000_000));\n        assertTrue(updateComment.body().contains(\"...\"));\n        assertTrue(updateComment.body().contains(\"2\"));\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"gitlab.repository\", \"gitlab.repository.branch\"})\n    void fileContentsNonExisting() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n        var branch = new Branch(props.get(\"gitlab.repository.branch\"));\n\n        var fileName = \"testfile-that-does-not-exist.txt\";\n        var returnedContents = gitLabRepo.fileContents(fileName, branch.name());\n        assertTrue(returnedContents.isEmpty());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\",\n                              \"gitlab.repository\", \"gitlab.repository.branch\"})\n    void writeFileContents() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n        var branch = new Branch(props.get(\"gitlab.repository.branch\"));\n\n        var fileName = \"testfile.txt\";\n\n        // Create new file\n        {\n            var fileContent = \"File content\";\n            gitLabRepo.writeFileContents(fileName, fileContent, branch,\n                    \"First commit message\", \"Duke\", \"duke@openjdk.org\", true);\n            var returnedContents = gitLabRepo.fileContents(fileName, branch.name());\n            assertEquals(fileContent, returnedContents.orElseThrow());\n        }\n\n        // Update file\n        {\n            var fileContent = \"New file content\";\n            gitLabRepo.writeFileContents(fileName, fileContent, branch,\n                    \"Second commit message\", \"Duke\", \"duke@openjdk.org\", false);\n            var returnedContents = gitLabRepo.fileContents(fileName, branch.name());\n            assertEquals(fileContent, returnedContents.orElseThrow());\n        }\n\n        // Make the file huge\n        {\n            var fileContent = \"a\".repeat(1024 * 1024 * 10);\n            gitLabRepo.writeFileContents(fileName, fileContent, branch,\n                    \"Third commit message\", \"Duke\", \"duke@openjdk.org\", false);\n            var returnedContents = gitLabRepo.fileContents(fileName, branch.name());\n            assertEquals(fileContent, returnedContents.orElseThrow());\n        }\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"gitlab.repository\"})\n    void branchProtection() throws IOException {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n        var branchName = \"pr/4711\";\n\n        gitLabRepo.protectBranchPattern(branchName);\n        // Don't fail on repeated invocations\n        gitLabRepo.protectBranchPattern(branchName);\n\n        try (var tempDir = new TemporaryDirectory()) {\n            var localRepoDir = tempDir.path().resolve(\"local\");\n            var localRepo = GitRepository.clone(gitLabRepo.authenticatedUrl(), localRepoDir, false, null);\n            var head = localRepo.head();\n            localRepo.push(head, gitLabRepo.authenticatedUrl(), branchName, true);\n\n            gitLabRepo.unprotectBranchPattern(branchName);\n            // Don't fail on repeated invocations\n            gitLabRepo.unprotectBranchPattern(branchName);\n\n            gitLabRepo.deleteBranch(branchName);\n        }\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"gitlab.repository\", \"gitlab.merge.request.id\"})\n    void testLastMarkedAsDraftTime() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n        var gitLabMergeRequest = gitLabRepo.pullRequest(props.get(\"gitlab.merge.request.id\"));\n\n        var lastMarkedAsDraftTime = gitLabMergeRequest.lastMarkedAsDraftTime();\n        assertEquals(\"2023-02-11T08:43:52.408Z\", lastMarkedAsDraftTime.orElseThrow().toString());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"gitlab.repository\", \"gitlab.targetRef\", \"gitlab.sourceRef\"})\n    void testDraftMR() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n\n        var gitLabMergeRequest = gitLabRepo.createPullRequest(gitLabRepo, props.get(\"gitlab.targetRef\"),\n                props.get(\"gitlab.sourceRef\"), \"Test\", List.of(\"test\"), true);\n        assertTrue(gitLabMergeRequest.isDraft());\n        assertEquals(\"Draft: Test\", gitLabMergeRequest.title());\n\n        gitLabMergeRequest.makeNotDraft();\n        gitLabMergeRequest = gitLabRepo.pullRequest(gitLabMergeRequest.id());\n        assertFalse(gitLabMergeRequest.isDraft());\n        assertEquals(\"Test\", gitLabMergeRequest.title());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"gitlab.repository\", \"gitlab.merge.request.id\", \"comment_html_url\",\n                              \"reviewComment_html_url\", \"review_html_url\"})\n    void testHtmlUrl() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n        var gitLabMergeRequest = gitLabRepo.pullRequest(props.get(\"gitlab.merge.request.id\"));\n\n        var comment = gitLabMergeRequest.comments().get(0);\n        assertEquals(props.get(\"comment_html_url\"), gitLabMergeRequest.commentUrl(comment).toString());\n\n        var reviewComment = gitLabMergeRequest.reviewComments().get(0);\n        assertEquals(props.get(\"reviewComment_html_url\"), gitLabMergeRequest.reviewCommentUrl(reviewComment).toString());\n\n        var review = gitLabMergeRequest.reviews().get(0);\n        assertEquals(props.get(\"review_html_url\"), gitLabMergeRequest.reviewUrl(review).toString());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"gitlab.repository\"})\n    void testDeleteDeployKey() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n        gitLabRepo.deleteDeployKeys(Duration.ofHours(24));\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"gitlab.repository\"})\n    void testDeployKeyTitles() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n        var expiredDeployKeys = gitLabRepo.deployKeyTitles(Duration.ofMinutes(5));\n        assertTrue(expiredDeployKeys.contains(\"test1\"));\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"gitlab.repository\", \"gitlab.prId\", \"gitlab.commitHash\"})\n    void testBackportCleanIgnoreCopyRight() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n\n        var pr = gitLabRepo.pullRequest(props.get(\"gitlab.prId\"));\n        var hash = new Hash(props.get(\"gitlab.commitHash\"));\n        var repoName = pr.repository().forge().search(hash, true);\n        assertTrue(repoName.isPresent());\n        var repository = pr.repository().forge().repository(repoName.get());\n        assertTrue(repository.isPresent());\n        var commit = repository.get().commit(hash, true);\n        var backportDiff = commit.orElseThrow().parentDiffs().get(0);\n        var prDiff = pr.diff();\n        var isClean = DiffComparator.areFuzzyEqual(backportDiff, prDiff);\n        assertTrue(isClean);\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"commit.comments.gitlab.repository\", \"commit.comments.hash\"})\n    void testCommitComments() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"commit.comments.gitlab.repository\")).orElseThrow();\n        var commitHash = new Hash(props.get(\"commit.comments.hash\"));\n\n        var comments = gitLabRepo.commitComments(commitHash);\n\n        assertFalse(comments.isEmpty());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"commit.comments.gitlab.repository\", \"commit.comments.local.repository\"})\n    void testRecentCommitComments() throws IOException {\n        var gitLabRepo = gitLabHost.repository(props.get(\"commit.comments.gitlab.repository\")).orElseThrow();\n\n        var localRepo = GitRepository.get(Path.of(props.get(\"commit.comments.local.repository\"))).orElseThrow();\n\n        var comments = gitLabRepo.recentCommitComments(localRepo, Set.of(), List.of(new Branch(\"master\")),\n                ZonedDateTime.now().minus(Duration.ofDays(4)));\n\n        assertFalse(comments.isEmpty());\n\n        // Pause here to update the local repository to trigger another code path\n        var comments2 = gitLabRepo.recentCommitComments(localRepo, Set.of(), List.of(new Branch(\"master\")),\n                ZonedDateTime.now().minus(Duration.ofDays(4)));\n\n        assertFalse(comments2.isEmpty());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"gitlab.repository\", \"gitlab.repository.branch\"})\n    void testDefaultBranchName() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n        assertEquals(props.get(\"gitlab.repository.branch\"), gitLabRepo.defaultBranchName());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\"})\n    void testGetUser() {\n        var userName = props.get(\"gitlab.user\");\n        var userByName = gitLabHost.user(userName).orElseThrow();\n        var userById = gitLabHost.userById(userByName.id()).orElseThrow();\n        assertEquals(userByName, userById);\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"gitlab.repository\"})\n    void testCollaborators() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n        var collaborators = gitLabRepo.collaborators();\n        assertNotNull(collaborators);\n    }\n\n    /**\n     * Expects:\n     * gitlab.collaborators.repository: GitLab repository where user has admin access\n     * gitlab.collaborators.user: User not currently a collaborator in repository\n     */\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n                              \"gitlab.collaborators.repository\", \"gitlab.collaborators.user\"})\n    void addRemoveCollaborator() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.collaborators.repository\")).orElseThrow();\n        var userName = props.get(\"gitlab.collaborators.user\");\n        var user = gitLabRepo.forge().user(userName).orElseThrow();\n        gitLabRepo.addCollaborator(user, false);\n        {\n            var collaborators = gitLabRepo.collaborators();\n            var collaborator = collaborators.stream()\n                    .filter(c -> c.user().username().equals(userName))\n                    .findAny().orElseThrow();\n            assertFalse(collaborator.canPush());\n        }\n        gitLabRepo.removeCollaborator(user);\n        {\n            var collaborators = gitLabRepo.collaborators();\n            var collaborator = collaborators.stream()\n                    .filter(c -> c.user().username().equals(userName))\n                    .findAny();\n            assertTrue(collaborator.isEmpty());\n        }\n        gitLabRepo.addCollaborator(user, true);\n        {\n            var collaborators = gitLabRepo.collaborators();\n            var collaborator = collaborators.stream()\n                    .filter(c -> c.user().username().equals(userName))\n                    .findAny().orElseThrow();\n            assertTrue(collaborator.canPush());\n        }\n        gitLabRepo.removeCollaborator(user);\n        {\n            var collaborators = gitLabRepo.collaborators();\n            var collaborator = collaborators.stream()\n                    .filter(c -> c.user().username().equals(userName))\n                    .findAny();\n            assertTrue(collaborator.isEmpty());\n        }\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n            \"gitlab.repository\", \"gitlab.merge.request.id\", \"gitlab.repository.commitHash\"})\n    void testDiffComplete() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n        var commit = gitLabRepo.commit(new Hash(props.get(\"gitlab.repository.commitHash\")), true);\n        var diff = commit.get().parentDiffs().get(0);\n        assertTrue(diff.complete());\n\n        var pr = gitLabRepo.pullRequest(props.get(\"gitlab.merge.request.id\"));\n        var prDiff = pr.diff();\n        assertFalse(prDiff.complete());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"gitlab.user\", \"gitlab.pat\", \"gitlab.uri\", \"gitlab.group\",\n            \"gitlab.repository\", \"gitlab.merge.request.id\"})\n    void testLastCommitTIME() {\n        var gitLabRepo = gitLabHost.repository(props.get(\"gitlab.repository\")).orElseThrow();\n\n        var pr = gitLabRepo.pullRequest(props.get(\"gitlab.merge.request.id\"));\n        var lastTouchedTime = pr.lastTouchedTime();\n    }\n}\n"
  },
  {
    "path": "gradle/wrapper/gradle-wrapper.properties",
    "content": "# Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n#\n# This code is free software; you can redistribute it and/or modify it\n# under the terms of the GNU General Public License version 2 only, as\n# published by the Free Software Foundation.\n#\n# This code is distributed in the hope that it will be useful, but WITHOUT\n# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n# version 2 for more details (a copy is included in the LICENSE file that\n# accompanied this code).\n#\n# You should have received a copy of the GNU General Public License version\n# 2 along with this work; if not, write to the Free Software Foundation,\n# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n#\n# Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n# or visit www.oracle.com if you need additional information or have any\n# questions.\n\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-8.5-bin.zip\ndistributionSha256Sum=9d926787066a081739e8200858338b4a69e837c3a821a33aca9db09dd4a41026\n"
  },
  {
    "path": "gradle.properties",
    "content": "org.gradle.vfs.watch=true\norg.gradle.parallel=true\norg.gradle.jvmargs=-Xmx2048M\n"
  },
  {
    "path": "gradlew",
    "content": "#!/bin/sh\n\n# Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n#\n# This code is free software; you can redistribute it and/or modify it\n# under the terms of the GNU General Public License version 2 only, as\n# published by the Free Software Foundation.\n#\n# This code is distributed in the hope that it will be useful, but WITHOUT\n# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n# version 2 for more details (a copy is included in the LICENSE file that\n# accompanied this code).\n#\n# You should have received a copy of the GNU General Public License version\n# 2 along with this work; if not, write to the Free Software Foundation,\n# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n#\n# Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n# or visit www.oracle.com if you need additional information or have any\n# questions.\n\nset -e\n\ndie() {\n    echo \"$1\" 1>&2\n    exit 1\n}\n\nexists() {\n    command -v \"$1\" >/dev/null 2>&1\n}\n\ndownload() {\n    URL=\"$1\"\n    FILENAME=\"$(basename $2)\"\n    DIRECTORY=\"$(dirname $2)\"\n    if exists curl; then\n        curl -L \"${URL}\" -o \"${FILENAME}\"\n        mv \"${FILENAME}\" \"${DIRECTORY}/${FILENAME}\"\n    elif exists wget; then\n        wget -O \"${DIRECTORY}/${FILENAME}\" \"${URL}\"\n    else\n        die \"error: neither 'wget' nor 'curl' available, can't download file\"\n    fi\n}\n\nchecksum() {\n    FILENAME=\"$1\"\n    SHA256=\"$2\"\n    if exists shasum; then\n        echo \"${SHA256}  ${FILENAME}\" | shasum -a 256 -c >/dev/null -\n        if [ \"$?\" != \"0\" ]; then\n            die \"error: did not get expected SHA256 hash for ${FILENAME}\"\n        fi\n    elif exists sha256sum; then\n        echo \"${SHA256}  ${FILENAME}\" | sha256sum -c >/dev/null -\n        if [ \"$?\" != \"0\" ]; then\n            die \"error: did not get expected SHA256 hash for ${FILENAME}\"\n        fi\n    else\n        die \"error: neither 'shasum' nor 'sha256sum' available, can't checksum file\"\n    fi\n}\n\nextract_tar() {\n    FILENAME=\"$1\"\n    DIRECTORY=\"$2\"\n    mkdir -p \"${DIRECTORY}\"\n\n    tar -xf \"${FILENAME}\" -C \"${DIRECTORY}\"\n}\n\nextract_zip() {\n    FILENAME=\"$1\"\n    DIRECTORY=\"$2\"\n\n    mkdir -p \"${DIRECTORY}\"\n    unzip \"${FILENAME}\" -d \"${DIRECTORY}\" > /dev/null\n}\n\nDIR=$(dirname $0)\nARCH=$(uname -m)\nOS=$(uname)\n\n. $(dirname \"${0}\")/deps.env\nif [ \"${ARCH}\" = \"x86_64\" ]; then\n    case \"${OS}\" in\n        Linux )\n            JDK_URL=\"${JDK_LINUX_X64_URL}\"\n            JDK_SHA256=\"${JDK_LINUX_X64_SHA256}\"\n            ;;\n        Darwin )\n            JDK_URL=\"${JDK_MACOS_X64_URL}\"\n            JDK_SHA256=\"${JDK_MACOS_X64_SHA256}\"\n            ;;\n        CYGWIN_NT* )\n            JDK_URL=\"${JDK_WINDOWS_X64_URL}\"\n            JDK_SHA256=\"${JDK_WINDOWS_X64_SHA256}\"\n            ;;\n    esac\nelif [ \"${ARCH}\" = \"arm64\" -o \"${ARCH}\" = \"aarch64\" ]; then\n    case \"${OS}\" in\n        Darwin )\n            JDK_URL=\"${JDK_MACOS_AARCH64_URL}\"\n            JDK_SHA256=\"${JDK_MACOS_AARCH64_SHA256}\"\n            ;;\n        Linux )\n            JDK_URL=\"${JDK_LINUX_AARCH64_URL}\"\n            JDK_SHA256=\"${JDK_LINUX_AARCH64_SHA256}\"\n            ;;\n    esac\nfi\n\nif [ -z \"${HTTPS_PROXY}\" -a -z \"${https_proxy}\" -a -z \"${HTTP_PROXY}\" -a -z \"${http_proxy}\" ]; then\n    # No HTTP(S) proxy configured via environment, check if configured via Git\n    if exists git; then\n        GIT_HTTP_PROXY=\"$(git config http.proxy || true)\"\n        if [ ! -z \"${GIT_HTTP_PROXY}\" ]; then\n            export HTTPS_PROXY=\"${GIT_HTTP_PROXY}\"\n            export https_proxy=\"${GIT_HTTP_PROXY}\"\n            export HTTP_PROXY=\"${GIT_HTTP_PROXY}\"\n            export http_proxy=\"${GIT_HTTP_PROXY}\"\n        fi\n    fi\nfi\n\nif [ ! -z \"${JDK_URL}\" ]; then\n    JDK_FILENAME=\"${DIR}/.jdk/$(basename ${JDK_URL})\"\n    if [ \"${OS}\" = \"Linux\" -o \"${OS}\" = \"Darwin\" ]; then\n        JDK_DIR=\"${DIR}/.jdk/$(basename -s '.tar.gz' ${JDK_URL})\"\n    else\n        JDK_DIR=\"${DIR}/.jdk/$(basename -s '.zip' ${JDK_URL})\"\n    fi\n\n    if [ ! -d \"${JDK_DIR}\" ]; then\n        mkdir -p ${DIR}/.jdk\n        if [ ! -f \"${JDK_FILENAME}\" ]; then\n            if [ -f \"${JDK_URL}\" ]; then\n                echo \"Copying JDK...\"\n                cp \"${JDK_URL}\" \"${JDK_FILENAME}\"\n            else\n                echo \"Downloading JDK...\"\n                download ${JDK_URL} \"${JDK_FILENAME}\"\n                checksum \"${JDK_FILENAME}\" ${JDK_SHA256}\n            fi\n        fi\n        echo \"Extracting JDK...\"\n        if [ \"${OS}\" = \"Linux\" -o \"${OS}\" = \"Darwin\" ]; then\n            extract_tar \"${JDK_FILENAME}\" \"${JDK_DIR}\"\n        else\n            extract_zip \"${JDK_FILENAME}\" \"${JDK_DIR}\"\n        fi\n    fi\n\n    if [ \"${OS}\" = \"Darwin\" ]; then\n        EXECUTABLE_FILTER='-perm +111'\n        LAUNCHER='java'\n    elif [ \"${OS}\" = \"Linux\" ]; then\n        EXECUTABLE_FILTER='-executable'\n        LAUNCHER='java'\n    else\n        LAUNCHER='java.exe'\n    fi\n\n    JAVA_LAUNCHER=$(find \"${JDK_DIR}\" -type f ${EXECUTABLE_FILTER} | grep \".*/bin/${LAUNCHER}$\")\n    export JAVA_HOME=\"$(dirname $(dirname ${JAVA_LAUNCHER}))\"\nelse\n    JAVA_LAUNCHER=\"java\"\nfi\n\nGRADLE_FILENAME=\"${DIR}/.gradle/$(basename ${GRADLE_URL})\"\nGRADLE_DIR=\"${DIR}/.gradle/$(basename -s '.zip' ${GRADLE_URL})\"\n\nif [ ! -d \"${GRADLE_DIR}\" ]; then\n    mkdir -p \"${DIR}/.gradle\"\n    if [ ! -f \"${GRADLE_FILENAME}\" ]; then\n        echo \"Downloading Gradle...\"\n        download ${GRADLE_URL} \"${GRADLE_FILENAME}\"\n    fi\n    checksum ${GRADLE_FILENAME} ${GRADLE_SHA256}\n    echo \"Extracting Gradle...\"\n    if [ \"${OS}\" = \"Linux\" -o \"${OS}\" = \"Darwin\" ]; then\n        if exists unzip; then\n            extract_zip \"${GRADLE_FILENAME}\" \"${GRADLE_DIR}\"\n        else\n            \"${JAVA_LAUNCHER}\" \"${DIR}\"/Unzip.java \"${GRADLE_FILENAME}\" \"${GRADLE_DIR}\"\n        fi\n    else\n        extract_zip \"${GRADLE_FILENAME}\" \"${GRADLE_DIR}\"\n    fi\nfi\n\nGRADLE_LAUNCHER=$(find \"${GRADLE_DIR}\" | grep '.*/bin/gradle$')\nchmod u+x \"${GRADLE_LAUNCHER}\"\n\nif [ \"${OS}\" = \"Linux\" ]; then\n    export LC_ALL=en_US.UTF-8\n    export LANG=en_US.UTF-8\n    export LANGUAGE=en_US.UTF-8\n    export TERM=${TERM:-dumb}\nfi\n\nexec \"${GRADLE_LAUNCHER}\" \"$@\"\n"
  },
  {
    "path": "gradlew.bat",
    "content": "@echo off\r\nrem Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\r\nrem DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\r\nrem\r\nrem This code is free software; you can redistribute it and/or modify it\r\nrem under the terms of the GNU General Public License version 2 only, as\r\nrem published by the Free Software Foundation.\r\nrem\r\nrem This code is distributed in the hope that it will be useful, but WITHOUT\r\nrem ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\r\nrem FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\r\nrem version 2 for more details (a copy is included in the LICENSE file that\r\nrem accompanied this code).\r\nrem\r\nrem You should have received a copy of the GNU General Public License version\r\nrem 2 along with this work; if not, write to the Free Software Foundation,\r\nrem Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\r\nrem\r\nrem Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\r\nrem or visit www.oracle.com if you need additional information or have any\r\nrem questions.\r\n\r\nfor /f \"tokens=1,2 delims==\" %%A in (deps.env) do (set %%A=%%~B)\r\nfor /f %%i in (\"%JDK_WINDOWS_X64_URL%\") do set JDK_WINDOWS_DIR=%%~ni\r\nfor /f %%i in (\"%GRADLE_URL%\") do set GRADLE_DIR=%%~ni\r\n\r\nif exist %~dp0\\.jdk\\%JDK_WINDOWS_DIR%.zip goto extractJdk\r\n\r\necho Downloading JDK...\r\nmkdir %~dp0\\.jdk\r\ncurl -L %JDK_WINDOWS_X64_URL% -o %JDK_WINDOWS_DIR%.zip\r\nmove %JDK_WINDOWS_DIR%.zip %~dp0\\.jdk\\\r\nfor /f \"tokens=*\" %%i in ('@certutil -hashfile %~dp0/.jdk/%JDK_WINDOWS_DIR%.zip sha256 ^| %WINDIR%\\System32\\find /v \"hash of file\" ^| %WINDIR%\\System32\\find /v \"CertUtil\"') do set SHA256JDK=%%i\r\nif \"%SHA256JDK%\" == \"%JDK_WINDOWS_X64_SHA256%\" (goto extractJdk)\r\necho Invalid SHA256 for JDK detected (%SHA256JDK%)\r\ngoto done\r\n\r\n:extractJdk\r\nif exist %~dp0\\.jdk\\%JDK_WINDOWS_DIR% goto gradle\r\n\r\necho Extracting JDK...\r\nmd %~dp0\\.jdk\\%JDK_WINDOWS_DIR%\r\n%WINDIR%\\System32\\tar -xf %~dp0/.jdk/%JDK_WINDOWS_DIR%.zip -C %~dp0/.jdk/%JDK_WINDOWS_DIR%\\\r\n\r\n:gradle\r\nif exist %~dp0\\.gradle\\%GRADLE_DIR%.zip goto extractGradle\r\n\r\necho Downloading Gradle...\r\nmkdir %~dp0\\.gradle\r\ncurl -L %GRADLE_URL% -o %GRADLE_DIR%.zip\r\nmove %GRADLE_DIR%.zip %~dp0\\.gradle\\\r\nfor /f \"tokens=*\" %%i in ('@certutil -hashfile %~dp0/.gradle/%GRADLE_DIR%.zip sha256 ^| %WINDIR%\\System32\\find /v \"hash of file\" ^| %WINDIR%\\System32\\find /v \"CertUtil\"') do set SHA256GRADLE=%%i\r\nif \"%SHA256GRADLE%\" == \"%GRADLE_SHA256%\" (goto extractGradle)\r\necho Invalid SHA256 for Gradle detected (%SHA256GRADLE%)\r\ngoto done\r\n\r\n:extractGradle\r\nif exist %~dp0\\.gradle\\%GRADLE_DIR% goto run\r\n\r\necho Extracting Gradle...\r\nmd %~dp0\\.gradle\\%GRADLE_DIR%\r\n%WINDIR%\\System32\\tar -xf %~dp0/.gradle/%GRADLE_DIR%.zip -C %~dp0/.gradle/%GRADLE_DIR%\r\n\r\n:run\r\nfor /d %%i in (%~dp0.jdk\\%JDK_WINDOWS_DIR%\\*) do set JAVA_HOME=%%i\r\nfor /d %%i in (%~dp0.gradle\\%GRADLE_DIR%\\*) do set GRADLE_HOME=%%i\r\n%GRADLE_HOME%\\bin\\gradle %*\r\n\r\n:done\r\n"
  },
  {
    "path": "host/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.host'\n    test {\n        requires 'org.openjdk.skara.test'\n        requires 'org.junit.jupiter.api'\n        opens 'org.openjdk.skara.host' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    testImplementation project(':test')\n}\n\npublishing {\n    publications {\n        host(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "host/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.host {\n    exports org.openjdk.skara.host;\n}\n"
  },
  {
    "path": "host/src/main/java/org/openjdk/skara/host/Credential.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.host;\n\npublic class Credential {\n    private final String username;\n    private final String password;\n\n    public Credential(String username, String password) {\n        this.username = username;\n        this.password = password;\n    }\n\n    public String password() {\n        return password;\n    }\n\n    public String username() {\n        return username;\n    }\n}\n"
  },
  {
    "path": "host/src/main/java/org/openjdk/skara/host/Host.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.host;\n\nimport java.time.Duration;\nimport java.util.Optional;\n\npublic interface Host {\n    boolean isValid();\n    Optional<HostUser> user(String username);\n    HostUser currentUser();\n    boolean isMemberOf(String groupId, HostUser user);\n    String hostname();\n    /**\n     * The precision at which timeStamp based queries are supported for this\n     * Host. The default is 1 nanosecond, knowing this can be used to avoid\n     * re-querying for the same Issues over and over (as timestamp based\n     * queries are often inclusive).\n     */\n    default Duration timeStampQueryPrecision() {\n        return Duration.ofNanos(1);\n    }\n}\n"
  },
  {
    "path": "host/src/main/java/org/openjdk/skara/host/HostUser.java",
    "content": "/*\n * Copyright (c) 2018, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.host;\n\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.function.Supplier;\n\npublic class HostUser {\n    private String id;\n    private String username;\n    private String fullName;\n    private String email;\n    private boolean active;\n    private boolean hasUpdated;\n    private final Supplier<HostUser> supplier;\n\n    public static class Builder {\n        private String id;\n        private String username;\n        private String fullName;\n        private String email;\n        private boolean active = true;\n        private Supplier<HostUser> supplier;\n\n        public Builder id(int id) {\n            this.id = String.valueOf(id);\n            return this;\n        }\n\n        public Builder id(String id) {\n            this.id = id;\n            return this;\n        }\n\n        public Builder username(String username) {\n            this.username = username;\n            return this;\n        }\n\n        public Builder fullName(String fullName) {\n            this.fullName = fullName;\n            return this;\n        }\n\n        public Builder email(String email) {\n            this.email = email;\n            return this;\n        }\n\n        public Builder active(boolean active) {\n            this.active = active;\n            return this;\n        }\n\n        public Builder supplier(Supplier<HostUser> supplier) {\n            this.supplier = supplier;\n            return this;\n        }\n\n        public HostUser build() {\n            return new HostUser(id, username, fullName, email, active, supplier);\n        }\n    }\n\n    private HostUser(String id, String username, String fullName, String email, boolean active, Supplier<HostUser> supplier) {\n        this.id = id;\n        this.username = username;\n        this.fullName = fullName;\n        this.email = email;\n        this.active = active;\n        this.hasUpdated = false;\n        this.supplier = supplier;\n    }\n\n    public static Builder builder() {\n        return new Builder();\n    }\n\n    public static HostUser create(String id, String username, String fullName) {\n        return builder().id(id).username(username).fullName(fullName).build();\n    }\n\n    public static HostUser create(String id, String username, String fullName, boolean active) {\n        return builder().id(id).username(username).fullName(fullName).active(active).build();\n    }\n\n    public static HostUser create(int id, String username, String fullName) {\n        return builder().id(id).username(username).fullName(fullName).build();\n    }\n\n    @Override\n    public boolean equals(Object other) {\n        if (this == other) {\n            return true;\n        }\n        if (other == null || getClass() != other.getClass()) {\n            return false;\n        }\n        HostUser o = (HostUser) other;\n        return Objects.equals(id(), o.id()) &&\n               Objects.equals(username(), o.username());\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(id, username);\n    }\n\n    private void update() {\n        if (hasUpdated) {\n            return;\n        }\n        var result = supplier.get();\n        id = result.id;\n        username = result.username;\n        fullName = result.fullName;\n        email = result.email;\n        active = result.active;\n        hasUpdated = true;\n    }\n\n    public String id() {\n        if (id == null) {\n            update();\n        }\n        return id;\n    }\n\n    public String username() {\n        if (username == null) {\n            update();\n        }\n        return username;\n    }\n\n    public String fullName() {\n        if (fullName == null) {\n            update();\n        }\n        // If the user doesn't set full name, then use username instead\n        if (fullName == null) {\n            return username();\n        }\n        return fullName;\n    }\n\n    public Optional<String> email() {\n        if (id == null || username == null || fullName == null) {\n            update();\n        }\n        return Optional.ofNullable(email);\n    }\n\n    public boolean active() {\n        return active;\n    }\n\n    @Override\n    public String toString() {\n        return \"HostUserDetails{\" +\n                \"id=\" + id +\n                \", username='\" + username + '\\'' +\n                \", fullName='\" + fullName + '\\'' +\n                '}';\n    }\n\n    public void changeUserName(String username) {\n        this.username = username;\n    }\n}\n"
  },
  {
    "path": "ini/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.ini'\n    test {\n        requires 'org.junit.jupiter.api'\n        opens 'org.openjdk.skara.ini' to 'org.junit.platform.commons'\n    }\n}\n\npublishing {\n    publications {\n        ini(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "ini/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.ini {\n    exports org.openjdk.skara.ini;\n}\n"
  },
  {
    "path": "ini/src/main/java/org/openjdk/skara/ini/INI.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.ini;\n\nimport java.util.*;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\n\npublic class INI {\n    public static class Value {\n        private final String value;\n\n        Value(String value) {\n            this.value = value;\n        }\n\n        public int asInt() {\n            return Integer.parseInt(value);\n        }\n\n        public double asDouble() {\n            return Double.parseDouble(value);\n        }\n\n        public boolean asBoolean() {\n            return Boolean.parseBoolean(value);\n        }\n\n        public String asString() {\n            return value;\n        }\n\n        public List<String> asList() {\n            return asList(Function.identity());\n        }\n\n        public <R> List<R> asList(Function <String, ? extends R> mapper) {\n            return Arrays.asList(value.split(\",\"))\n                         .stream()\n                         .map(String::trim)\n                         .map(mapper)\n                         .collect(Collectors.toList());\n        }\n\n        public <R> R as(Function <String, ? extends R> mapper) {\n            return mapper.apply(value);\n        }\n\n        @Override\n        public String toString() {\n            return value;\n        }\n    }\n\n    private final Map<String, Section> sections;\n    private final Map<String, Value> entries;\n\n    INI(Map<String, Section> sections, Map<String, Value> entries) {\n        this.sections = sections;\n        this.entries = entries;\n    }\n\n    public Collection<Section> sections() {\n        return sections.values();\n    }\n\n    public Section section(String name) {\n        return sections.get(name);\n    }\n\n    public boolean hasSection(String name) {\n        return sections.containsKey(name);\n    }\n\n    public List<Section.Entry> entries() {\n        return entries.entrySet()\n                      .stream()\n                      .map(Section.Entry::new)\n                      .collect(Collectors.toList());\n    }\n\n    public Value get(String key) {\n        return entries.get(key);\n    }\n\n    public boolean contains(String key) {\n        return entries.containsKey(key);\n    }\n\n    @Override\n    public String toString() {\n        var sb = new StringBuilder();\n\n        for (var entry : entries()) {\n            sb.append(entry.toString());\n            sb.append(\"\\n\");\n        }\n\n        for (var section : sections()) {\n            sb.append(section.toString());\n            sb.append(\"\\n\");\n        }\n\n        return sb.toString();\n    }\n\n    private static void fail(int line,String message) {\n        var m = String.format(\"line %d: %s\", line, message);\n        throw new IllegalArgumentException(m);\n    }\n\n    public static INI parse(String s) {\n        return parse(Arrays.asList(s.split(\"\\n\")));\n    }\n\n    public static INI parse(List<String> lines) {\n        var globalEntries = new HashMap<String, Value>();\n        var sections = new HashMap<String, Section>();\n\n        Section current = null;\n        for (var i = 0; i < lines.size(); i++) {\n            var line = lines.get(i);\n            if (line.isEmpty() || line.startsWith(\";\")) {\n                continue;\n            }\n\n            if (line.startsWith(\"[\")) {\n                if (!line.endsWith(\"]\")) {\n                    fail(i, \"section header must end with ']'\");\n                }\n\n                var content = line.substring(1, line.length() - 1); \n                var parts = content.split(\" \");\n                if (parts.length > 2) {\n                    fail(i, \"section header must be of format '[name (\\\"subsection\\\")?]'\");\n                }\n\n                var name = parts[0];\n                if (parts.length == 1) {\n                    if (sections.containsKey(name)) {\n                        current = sections.get(name);\n                    } else {\n                        current = new Section(name);\n                        sections.put(current.name(), current);\n                    }\n                } else {\n                    var subsection = parts[1];\n                    if (!(subsection.startsWith(\"\\\"\") && subsection.endsWith(\"\\\"\"))) {\n                        fail(i, \"section header must be of format '[name (\\\"subsection\\\")?]'\");\n                    }\n\n                    var subsectionName = subsection.substring(1, subsection.length() - 1);\n                    if (subsectionName.equals(\"\")) {\n                        fail(i, \"subsection must have a name\");\n                    }\n\n                    if (!sections.containsKey(name)) {\n                        fail(i, \"subsection to an unknown section '\" + name + \"'\");\n                    }\n\n                    var section = sections.get(name);\n                    if (section.hasSubsection(subsectionName)) {\n                        current = section.subsection(subsectionName);\n                    } else {\n                        current = new Section(subsectionName);\n                        section.addSubsection(current);\n                    }\n                }\n            } else {\n                if (!line.contains(\"=\")) {\n                    fail(i, \"entry must be of form 'key = value'\");\n                }\n                var splitIndex = line.indexOf(\"=\");\n                var key = line.substring(0, splitIndex).trim();\n                var value = line.substring(splitIndex + 1, line.length()).trim();\n\n                if (current == null) {\n                    globalEntries.put(key, new Value(value));\n                } else {\n                    current.put(key, value);\n                }\n            }\n        }\n\n        return new INI(sections, globalEntries);\n    }\n}\n"
  },
  {
    "path": "ini/src/main/java/org/openjdk/skara/ini/Section.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.ini;\n\nimport java.util.*;\nimport java.util.stream.Collectors;\n\npublic class Section {\n    public static class Entry {\n        private final String key;\n        private final INI.Value value;\n\n        Entry(Map.Entry<String, INI.Value> e) {\n            this.key = e.getKey();\n            this.value = e.getValue();\n        }\n\n        public String key() {\n            return key;\n        }\n\n        public INI.Value value() {\n            return value;\n        }\n\n        @Override\n        public String toString() {\n            return key + \" = \" + value.toString();\n        }\n    }\n    private final String name;\n    private final Map<String, INI.Value> entries;\n    private final Map<String, Section> subsections;\n\n    public Section(String name) {\n        this.name = name;\n        this.entries = new HashMap<>();\n        this.subsections = new HashMap<>();\n    }\n\n    public String name() {\n        return name;\n    }\n\n    public Section subsection(String name) {\n        return subsections.get(name);\n    }\n\n    public boolean hasSubsection(String name) {\n        return subsections.containsKey(name);\n    }\n\n    public Collection<Section> subsections() {\n        return subsections.values();\n    }\n\n    public INI.Value get(String key) {\n        return entries.get(key);\n    }\n\n    public int get(String key, int fallback) {\n        if (contains(key)) {\n            return entries.get(key).asInt();\n        }\n        return fallback;\n    }\n\n    public double get(String key, double fallback) {\n        if (contains(key)) {\n            return entries.get(key).asDouble();\n        }\n        return fallback;\n    }\n\n    public String get(String key, String fallback) {\n        if (contains(key)) {\n            return entries.get(key).asString();\n        }\n        return fallback;\n    }\n\n    public boolean get(String key, boolean fallback) {\n        if (contains(key)) {\n            return entries.get(key).asBoolean();\n        }\n        return fallback;\n    }\n\n    public List<String> get(String key, List<String> fallback) {\n        if (contains(key)) {\n            return entries.get(key).asList();\n        }\n        return fallback;\n    }\n\n    public boolean contains(String key) {\n        return entries.containsKey(key);\n    }\n\n    public List<Entry> entries() {\n        return entries.entrySet()\n                      .stream()\n                      .map(Entry::new)\n                      .collect(Collectors.toList());\n    }\n\n    void put(String key, String value) {\n        entries.put(key, new INI.Value(value));\n    }\n\n    void addSubsection(Section subsection) {\n        subsections.put(subsection.name(), subsection);\n    }\n\n    @Override\n    public String toString() {\n        var sb = new StringBuilder();\n\n        sb.append(\"[\");\n        sb.append(name);\n        sb.append(\"]\\n\");\n\n        for (var entry : entries()) {\n            sb.append(entry.toString());\n            sb.append(\"\\n\");\n        }\n\n        for (var subsection : subsections()) {\n            sb.append(\"[\");\n            sb.append(name);\n            sb.append(\" \\\"\");\n            sb.append(subsection.name());\n            sb.append(\"\\\"]\\n\");\n\n            for (var entry : subsection.entries()) {\n                sb.append(entry.toString());\n                sb.append(\"\\n\");\n            }\n        }\n\n        return sb.toString();\n    }\n}\n"
  },
  {
    "path": "ini/src/test/java/org/openjdk/skara/ini/INITests.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.ini;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class INITests {\n    @Test\n    void testOnlyGlobalEntries() {\n        var lines = List.of(\n            \"project=jdk\",\n            \"bugs=dup\",\n            \"whitespace=lax\"\n        );\n        var ini = INI.parse(lines);\n        assertEquals(\"jdk\", ini.get(\"project\").asString());\n        assertEquals(\"dup\", ini.get(\"bugs\").asString());\n        assertEquals(\"lax\", ini.get(\"whitespace\").asString());\n    }\n\n    @Test\n    void testWhitespaceInEntries() {\n        var lines = List.of(\n            \"project = jdk\",\n            \"\\t\\tbugs =    dup\",\n            \"\",\n            \"whitespace\\t=\\tlax\"\n        );\n        var ini = INI.parse(lines);\n        assertEquals(\"jdk\", ini.get(\"project\").asString());\n        assertEquals(\"dup\", ini.get(\"bugs\").asString());\n        assertEquals(\"lax\", ini.get(\"whitespace\").asString());\n    }\n\n    @Test\n    void testComments() {\n        var lines = List.of(\n            \"; this is a comment\",\n            \"project = jdk\",\n            \"\\t\\tbugs =    dup\",\n            \"\",\n            \"; this is another comment\",\n            \"whitespace\\t=\\tlax\"\n        );\n        var ini = INI.parse(lines);\n        assertEquals(\"jdk\", ini.get(\"project\").asString());\n        assertEquals(\"dup\", ini.get(\"bugs\").asString());\n        assertEquals(\"lax\", ini.get(\"whitespace\").asString());\n    }\n\n    @Test\n    void testOneSection() {\n        var lines = List.of(\n            \"[general]\",\n            \"    project = jdk\",\n            \"    bugs = dup\",\n            \"    whitespace = lax\"\n        );\n        var ini = INI.parse(lines);\n        assertEquals(\"jdk\", ini.section(\"general\").get(\"project\").asString());\n        assertEquals(\"dup\", ini.section(\"general\").get(\"bugs\").asString());\n        assertEquals(\"lax\", ini.section(\"general\").get(\"whitespace\").asString());\n    }\n\n    @Test\n    void testMultipleSections() {\n        var lines = List.of(\n            \"[general]\",\n            \"    project = jdk\",\n            \"\",\n            \"[checks]\",\n            \"    commits = author, whitespace, reviews\"\n        );\n        var ini = INI.parse(lines);\n        assertEquals(\"jdk\", ini.section(\"general\").get(\"project\").asString());\n        assertEquals(\"author, whitespace, reviews\", ini.section(\"checks\").get(\"commits\").asString());\n    }\n\n    @Test\n    void testMultipleSectionsAndSubsection() {\n        var lines = List.of(\n            \"[general]\",\n            \"    project = jdk\",\n            \"\",\n            \"[checks]\",\n            \"    commits = author, whitespace, reviews\",\n            \"\",\n            \"[checks \\\"whitespace\\\"]\",\n            \"    suffixes = .cpp, .c, .h, .java\"\n        );\n        var ini = INI.parse(lines);\n        assertEquals(\"jdk\", ini.section(\"general\").get(\"project\").asString());\n        assertEquals(\"author, whitespace, reviews\", ini.section(\"checks\").get(\"commits\").asString());\n        assertEquals(\".cpp, .c, .h, .java\", ini.section(\"checks\").subsection(\"whitespace\").get(\"suffixes\").asString());\n    }\n\n    @Test\n    void testAsList() {\n        var lines = List.of(\n            \"[a]\",\n            \"    chars = a, b, c, d, e\",\n            \"\",\n            \"[b]\",\n            \"    chars = a,b,c,d,e\",\n            \"\",\n            \"[c]\",\n            \"    numbers = 1, 2, 3, 4, 5\"\n        );\n\n        var ini = INI.parse(lines);\n        var chars = List.of(\"a\", \"b\", \"c\", \"d\", \"e\");\n        var numbers = List.of(1, 2, 3, 4, 5);\n\n        assertEquals(chars, ini.section(\"a\").get(\"chars\").asList());\n        assertEquals(chars, ini.section(\"b\").get(\"chars\").asList());\n        assertEquals(numbers, ini.section(\"c\").get(\"numbers\").asList(Integer::parseInt));\n    }\n\n    @Test\n    void testAsBoolean() {\n        var lines = List.of(\n            \"[a]\",\n            \"    a = true\",\n            \"    b = false\"\n        );\n\n        var ini = INI.parse(lines);\n\n        assertTrue(ini.section(\"a\").get(\"a\").asBoolean());\n        assertFalse(ini.section(\"a\").get(\"b\").asBoolean());\n    }\n\n    @Test\n    void testAsInt() {\n        var lines = List.of(\n            \"[a]\",\n            \"    a = 17\"\n        );\n\n        var ini = INI.parse(lines);\n\n        assertEquals(17, ini.section(\"a\").get(\"a\").asInt());\n    }\n\n    @Test\n    void testAsDouble() {\n        var lines = List.of(\n            \"[a]\",\n            \"    a = 17.7\"\n        );\n\n        var ini = INI.parse(lines);\n\n        assertEquals(17.7, ini.section(\"a\").get(\"a\").asDouble());\n    }\n\n    @Test\n    void testParseString() {\n        var ini = INI.parse(\"project=jdk\\nbugs=dup\");\n\n        assertEquals(\"jdk\", ini.get(\"project\").asString());\n        assertEquals(\"dup\", ini.get(\"bugs\").asString());\n    }\n\n    @Test\n    void testContains() {\n        var ini = INI.parse(\"project=jdk\");\n\n        assertTrue(ini.contains(\"project\"));\n        assertFalse(ini.contains(\"bugs\"));\n    }\n\n    @Test\n    void testUpdatingValueInGlobalSection() {\n        var lines = List.of(\n            \"foo=bar\",\n            \"foo=baz\"\n        );\n        var ini = INI.parse(lines);\n        assertEquals(\"baz\", ini.get(\"foo\").asString());\n    }\n\n    @Test\n    void testUpdatingValueInSection() {\n        var lines = List.of(\n            \"[checks]\",\n            \"    commits = reviews\",\n            \"\",\n            \"[checks]\",\n            \"    commits = none\"\n        );\n        var ini = INI.parse(lines);\n        assertEquals(\"none\", ini.section(\"checks\").get(\"commits\").asString());\n    }\n\n    @Test\n    void testUpdatingValueInSubsection() {\n        var lines = List.of(\n            \"[checks]\",\n            \"    commits = reviews\",\n            \"\",\n            \"[checks \\\"reviews\\\"]\",\n            \"    merge = ignore\",\n            \"\",\n            \"[checks \\\"reviews\\\"]\",\n            \"    merge = check\"\n        );\n        var ini = INI.parse(lines);\n        assertEquals(\"check\", ini.section(\"checks\").subsection(\"reviews\").get(\"merge\").asString());\n    }\n}\n"
  },
  {
    "path": "issuetracker/build.gradle",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.issuetracker'\n    test {\n        requires 'org.openjdk.skara.test'\n        requires 'org.junit.jupiter.api'\n        requires 'org.openjdk.skara.forge'\n        requires 'org.openjdk.skara.proxy'\n        requires 'jdk.httpserver'\n        opens 'org.openjdk.skara.issuetracker' to 'org.junit.platform.commons'\n        opens 'org.openjdk.skara.issuetracker.jira' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':vcs')\n    implementation project(':json')\n    implementation project(':ini')\n    implementation project(':process')\n    implementation project(':email')\n    implementation project(':network')\n    implementation project(':host')\n\n    testImplementation project(':test')\n    testImplementation project(':forge')\n    testImplementation project(':proxy')\n}\n\npublishing {\n    publications {\n        issuetracker(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.issuetracker {\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.json;\n    requires org.openjdk.skara.ini;\n    requires org.openjdk.skara.process;\n    requires org.openjdk.skara.email;\n    requires org.openjdk.skara.network;\n    requires transitive org.openjdk.skara.host;\n    requires java.net.http;\n    requires java.logging;\n\n    exports org.openjdk.skara.issuetracker;\n    exports org.openjdk.skara.issuetracker.jira;\n\n    uses org.openjdk.skara.issuetracker.IssueTrackerFactory;\n\n    provides org.openjdk.skara.issuetracker.IssueTrackerFactory with org.openjdk.skara.issuetracker.jira.JiraIssueTrackerFactory;\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/ActiveUserTracker.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker;\n\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\nimport org.openjdk.skara.host.HostUser;\n\n/**\n * Tracks and caches users active state in an IssueTracker. Caching should be thread safe.\n */\npublic class ActiveUserTracker {\n\n    private final IssueTracker issueTracker;\n    private final Map<String, Boolean> userActiveMap = new ConcurrentHashMap<>();\n\n    public ActiveUserTracker(IssueTracker issueTracker) {\n        this.issueTracker = issueTracker;\n    }\n\n    public boolean isUserActive(String userName) {\n        return userActiveMap.computeIfAbsent(userName, (u) -> issueTracker.user(u).map(HostUser::active).orElse(false));\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/Comment.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker;\n\nimport org.openjdk.skara.host.HostUser;\n\nimport java.time.ZonedDateTime;\nimport java.util.Objects;\n\npublic class Comment {\n    private final String id;\n    private final String body;\n    private final HostUser author;\n    private final ZonedDateTime createdAt;\n    private final ZonedDateTime updatedAt;\n\n    public Comment(String id, String body, HostUser author, ZonedDateTime createdAt, ZonedDateTime updatedAt) {\n        this.id = id;\n        this.body = body;\n        this.author = author;\n        this.createdAt = createdAt;\n        this.updatedAt = updatedAt;\n    }\n\n    public String id() {\n        return id;\n    }\n\n    public String body() {\n        return body;\n    }\n\n    public HostUser author() {\n        return author;\n    }\n\n    public ZonedDateTime createdAt() {\n        return createdAt;\n    }\n\n    public ZonedDateTime updatedAt() {\n        return updatedAt;\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        Comment comment = (Comment) o;\n        return Objects.equals(id, comment.id) &&\n                Objects.equals(body, comment.body) &&\n                Objects.equals(author, comment.author) &&\n                Objects.equals(createdAt, comment.createdAt) &&\n                Objects.equals(updatedAt, comment.updatedAt);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(id, body, author, createdAt, updatedAt);\n    }\n\n    @Override\n    public String toString() {\n        return body;\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/Issue.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker;\n\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.json.JSONValue;\n\nimport java.net.URI;\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\n/**\n * An interface for common aspects of different kinds of issues, either in a bug tracking\n * system or pull requests. In practice it's rare to operate on something that could be\n * either a bug or a pull request, so avoid using this interface directly.\n */\npublic interface Issue {\n    /**\n     * Project containing the issue.\n     * @return\n     */\n    IssueProject project();\n\n    /**\n     * The repository-specific identifier.\n     * @return\n     */\n    String id();\n\n    /**\n     * The host-specific author name.\n     * @return\n     */\n    HostUser author();\n\n    /**\n     * Title of the request. The implementation should make sure it is stripped.\n     * @return\n     */\n    String title();\n\n    /**\n     * Update the title of the request.\n     * @param title\n     */\n    void setTitle(String title);\n\n    /**\n     * The main body of the request.\n     * @return\n     */\n    String body();\n\n    /**\n     * Update the main body of the request.\n     * @param body\n     */\n    void setBody(String body);\n\n    /**\n     * All comments on the issue, in ascending creation time order.\n     * @return\n     */\n    List<Comment> comments();\n\n    /**\n     * Posts a new comment.\n     * @param body\n     */\n    Comment addComment(String body);\n\n    /**\n     * Remove the specific comment.\n     * @param comment\n     */\n    void removeComment(Comment comment);\n\n    /**\n     * Updates an existing comment.\n     * @param id\n     * @param body\n     */\n    Comment updateComment(String id, String body);\n\n    /**\n     * When the request was created.\n     * @return\n     */\n    ZonedDateTime createdAt();\n\n    /**\n     * When the request was last updated.\n     * @return\n     */\n    ZonedDateTime updatedAt();\n\n    enum State {\n        OPEN,\n        RESOLVED,\n        CLOSED\n    }\n\n    /**\n     * Returns the current state.\n     * @return\n     */\n    State state();\n\n    default boolean isOpen() {\n        return state() == State.OPEN;\n    }\n\n    default boolean isClosed() {\n        return state() == State.CLOSED;\n    }\n\n    default boolean isResolved() {\n        return state() == State.RESOLVED;\n    }\n\n    /**\n     * By default this issue is considered fixed if it has been resolved.\n     * For specific implementations, this may require additional criteria,\n     * like not having been rejected.\n     */\n    default boolean isFixed() {\n        return isResolved();\n    }\n\n    /**\n     * Set the state.\n     * @param state Desired state\n     */\n    void setState(State state);\n\n    /**\n     * Adds the given label.\n     * @param label\n     */\n    void addLabel(String label);\n\n    /**\n     * Removes the given label.\n     * @param label\n     */\n    void removeLabel(String label);\n    default void removeLabel(Label label) {\n        removeLabel(label.name());\n    }\n\n    /**\n     * Set the given labels and remove any others.\n     */\n    void setLabels(List<String> labels);\n\n    /**\n     * Retrieves all the currently set labels.\n     * @return\n     */\n    List<Label> labels();\n    default List<String> labelNames() {\n        return labels().stream().map(Label::name).collect(Collectors.toList());\n    }\n\n    /**\n     * Returns a link that will lead to the issue.\n     */\n    URI webUrl();\n\n    /**\n     * Returns a non-transformed link to the issue\n     */\n    default URI nonTransformedWebUrl() {\n        return webUrl();\n    }\n\n    /**\n     * Returns all usernames assigned to the issue.\n     */\n    List<HostUser> assignees();\n\n    /**\n     * Update the list of assignees.\n     * @param assignees\n     */\n    void setAssignees(List<HostUser> assignees);\n\n    Optional<HostUser> closedBy();\n\n    URI commentUrl(Comment comment);\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/IssueLinkBuilder.java",
    "content": "/*\n * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker;\n\npublic class IssueLinkBuilder {\n    private final IssueTrackerIssue linked;\n    private final String relationship;\n\n    IssueLinkBuilder(IssueTrackerIssue issue, String relationship) {\n        this.linked = issue;\n        this.relationship = relationship;\n    }\n\n    public Link build() {\n        return new Link(null, null, relationship, null, null, null, null, null, false, linked);\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/IssueProject.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker;\n\nimport org.openjdk.skara.json.JSONValue;\n\nimport java.net.URI;\nimport java.time.ZonedDateTime;\nimport java.util.*;\n\npublic interface IssueProject {\n    IssueTracker issueTracker();\n    URI webUrl();\n    IssueTrackerIssue createIssue(String title, List<String> body, Map<String, JSONValue> properties);\n    Optional<IssueTrackerIssue> issue(String id);\n    List<IssueTrackerIssue> issues();\n\n    /**\n     * Find all issues that have been updated after or on the given time, with\n     * a resolution given by Host::timeStampQueryPrecision.\n     */\n    List<IssueTrackerIssue> issues(ZonedDateTime updatedAfter);\n    String name();\n\n    /**\n     * Get the JEP issue according to the JEP ID.\n     * @param jepId JEP ID\n     * @return the corresponding issue\n     */\n    Optional<IssueTrackerIssue> jepIssue(String jepId);\n\n    /**\n     * Find all issues of CSR type updated after or on the given time, with\n     * a resolution given by Host::timeStampQueryPrecision.\n     * @param updatedAfter Timestamp\n     * @return List of issues found\n     */\n    List<IssueTrackerIssue> csrIssues(ZonedDateTime updatedAfter);\n\n    /**\n     * Find the last updated issue.\n     * @return The last updated issue, or empty if none exist\n     */\n    Optional<IssueTrackerIssue> lastUpdatedIssue();\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/IssueProjectPoller.java",
    "content": "/*\n * Copyright (c) 2022, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.ZonedDateTime;\nimport java.util.Comparator;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\n\npublic class IssueProjectPoller {\n\n    private static final Logger log = Logger.getLogger(IssueProjectPoller.class.getName());\n\n    private final IssueProject issueProject;\n    private final Duration timeStampQueryPrecision;\n    private final ZonedDateTime initialUpdatedAt;\n    private final Map<String, IssueTrackerIssue> retryMap = new HashMap<>();\n\n    record QueryResult(Map<String, IssueTrackerIssue> issues, ZonedDateTime maxUpdatedAt,\n                       Instant afterQuery, List<IssueTrackerIssue> result,\n                       /*\n                        * When enough time has passed since the last time we actually returned\n                        * results, it's possible to pad the updatedAt query parameter to avoid\n                        * receiving the same issues over and over, only to then filter them out.\n                        */\n                       boolean paddingPossible) {}\n    private QueryResult current;\n    private QueryResult prev;\n\n    /**\n     * @param issueProject The IssueProject to poll from\n     * @param startUpPadding The amount of historic time to include in the\n     *                       very first query\n     */\n    public IssueProjectPoller(IssueProject issueProject, Duration startUpPadding) {\n        this.issueProject = issueProject;\n        this.timeStampQueryPrecision = issueProject.issueTracker().timeStampQueryPrecision();\n        this.initialUpdatedAt = ZonedDateTime.now().minus(startUpPadding);\n    }\n\n    public List<IssueTrackerIssue> updatedIssues() {\n        var beforeQuery = Instant.now();\n        List<IssueTrackerIssue> issues = queryIssues();\n        var afterQuery = Instant.now();\n\n        // Convert the query result into a map\n        var issuesMap = issues.stream().collect(Collectors.toMap(Issue::id, i -> i));\n\n        // Find the max updatedAt value in the result set. Fall back on the previous\n        // value (happens if no results were returned), or the initialUpdatedAt (if\n        // no results have been found at all so far).\n        var maxUpdatedAt = issues.stream()\n                .map(Issue::updatedAt)\n                .max(Comparator.naturalOrder())\n                .orElseGet(() -> prev != null ? prev.maxUpdatedAt : initialUpdatedAt);\n\n        // Filter the results\n        var filtered = issues.stream()\n                .filter(this::isUpdated)\n                .toList();\n\n        // If nothing was left after filtering, update the paddingPossible state if enough time\n        // has passed since last we found something.\n        boolean paddingPossible = false;\n        if (filtered.isEmpty()) {\n            if (prev != null) {\n                // The afterQuery value that we save should be the time when we last\n                // found something after filtering.\n                afterQuery = prev.afterQuery;\n                if (prev.afterQuery.isBefore(beforeQuery.minus(timeStampQueryPrecision))) {\n                    paddingPossible = true;\n                }\n            }\n        }\n\n        var withRetries = addRetries(filtered);\n\n        // Save the state of the current query results\n        current = new QueryResult(issuesMap, maxUpdatedAt, afterQuery, withRetries, paddingPossible);\n\n        log.info(\"Found \" + withRetries.size() + \" updated issues for \" + issueProject.name());\n        return withRetries;\n    }\n\n    /**\n     * After calling updatedIssues(), this method must be called to acknowledge\n     * that all the issues returned have been handled. If not, the previous results will be\n     * included in the next call to updatedIssues() again.\n     */\n    public synchronized void lastBatchHandled() {\n        if (current != null) {\n            prev = current;\n            current = null;\n            // Remove any returned PRs from the retry/quarantine sets\n            prev.result.forEach(pr -> retryMap.remove(pr.id()));\n        }\n    }\n\n    public synchronized void retryIssue(IssueTrackerIssue issue) {\n        retryMap.put(issue.id(), issue);\n    }\n\n    private List<IssueTrackerIssue> queryIssues() {\n        ZonedDateTime queryAfter;\n        if (prev == null || prev.maxUpdatedAt == null) {\n            queryAfter = initialUpdatedAt;\n        } else if (prev.paddingPossible) {\n            // If we haven't found any actual results for long enough,\n            // we can pad on the query precision to avoid fetching the\n            // last returned issue over and over.\n            queryAfter = prev.maxUpdatedAt.plus(timeStampQueryPrecision);\n        } else {\n            queryAfter = prev.maxUpdatedAt;\n        }\n        log.fine(\"Fetching issues updated after \" + queryAfter);\n        return queryIssues(issueProject, queryAfter);\n    }\n\n    /**\n     * Subclasses can override this method to query for specific kinds of issues.\n     * @param issueProject IssueProject to run query on\n     * @param updatedAfter Timestamp for updatedAt query\n     */\n    protected List<IssueTrackerIssue> queryIssues(IssueProject issueProject, ZonedDateTime updatedAfter) {\n        return issueProject.issues(updatedAfter);\n    }\n\n    /**\n     * Evaluates if an issue has been updated since the previous query result.\n     */\n    private boolean isUpdated(IssueTrackerIssue issue) {\n        if (prev == null) {\n            return true;\n        }\n        var issuePrev = prev.issues.get(issue.id());\n        if (issuePrev == null || issue.updatedAt().isAfter(issuePrev.updatedAt())) {\n            return true;\n        }\n        if (!issuePrev.equals(issue)) {\n            return true;\n        }\n        return false;\n    }\n\n    /**\n     * Returns a list of all prs with retries added.\n     */\n    private synchronized List<IssueTrackerIssue> addRetries(List<IssueTrackerIssue> issues) {\n        if (retryMap.isEmpty()) {\n            return issues;\n        } else {\n            // Find the retries not already present in the issues list\n            var retries = retryMap.values().stream()\n                    .filter(retryIssue -> issues.stream().noneMatch(issue -> issue.id().equals(retryIssue.id())))\n                    .toList();\n            if (retries.isEmpty()) {\n                return issues;\n            } else {\n                return Stream.concat(issues.stream(), retries.stream()).toList();\n            }\n        }\n    }\n\n    // Expose the query results to tests\n    QueryResult getCurrentQueryResult() {\n        return current;\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/IssueTracker.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker;\n\nimport java.time.Duration;\nimport java.net.URI;\nimport java.util.Optional;\n\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.network.RestRequest;\nimport org.openjdk.skara.json.*;\n\n\npublic interface IssueTracker extends Host {\n    public interface CustomEndpointRequest {\n        CustomEndpointRequest body(JSONValue json);\n        CustomEndpointRequest header(String value, String name);\n        CustomEndpointRequest onError(RestRequest.ErrorTransform transform);\n\n        JSONValue execute();\n    }\n\n    public interface CustomEndpoint {\n        default CustomEndpointRequest post() {\n            throw new UnsupportedOperationException(\"HTTP method POST is not supported\");\n        }\n\n        default CustomEndpointRequest get() {\n            throw new UnsupportedOperationException(\"HTTP method GET is not supported\");\n        }\n\n        default CustomEndpointRequest put() {\n            throw new UnsupportedOperationException(\"HTTP method PUT is not supported\");\n        }\n\n        default CustomEndpointRequest patch() {\n            throw new UnsupportedOperationException(\"HTTP method PATCH is not supported\");\n        }\n\n        default CustomEndpointRequest delete() {\n            throw new UnsupportedOperationException(\"HTTP method DELETE is not supported\");\n        }\n    }\n\n    /**\n     * Creates and caches a new project if it hasn't been initialized.\n     * Subsequent calls with the same name will return the cached instance.\n     */\n    IssueProject project(String name);\n    Optional<CustomEndpoint> lookupCustomEndpoint(String path);\n    URI uri();\n\n    static IssueTracker from(String name, URI uri, Credential credential, JSONObject configuration) {\n        var factory = IssueTrackerFactory.getIssueTrackerFactories().stream()\n                                  .filter(f -> f.name().equals(name))\n                                  .findFirst();\n        if (factory.isEmpty()) {\n            throw new RuntimeException(\"No issue tracker factory named '\" + name + \"' found - check module path\");\n        }\n        return factory.get().create(uri, credential, configuration);\n    }\n\n    static IssueTracker from(String name, URI uri, Credential credential) {\n        return from(name, uri, credential, null);\n    }\n\n    static IssueTracker from(String name, URI uri) {\n        return from(name, uri, null, null);\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/IssueTrackerFactory.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker;\n\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.json.JSONObject;\n\nimport java.net.URI;\nimport java.util.*;\nimport java.util.stream.*;\n\npublic interface IssueTrackerFactory {\n    /**\n     * A user-friendly name for the given issue tracker, used for configuration section naming. Should be lower case.\n     * @return\n     */\n    String name();\n\n    /**\n     * Instantiate an instance of this issue tracker.\n     * @return\n     */\n    IssueTracker create(URI uri, Credential credential, JSONObject configuration);\n\n    static List<IssueTrackerFactory> getIssueTrackerFactories() {\n        return StreamSupport.stream(ServiceLoader.load(IssueTrackerFactory.class).spliterator(), false)\n                            .collect(Collectors.toList());\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/IssueTrackerIssue.java",
    "content": "/*\n * Copyright (c) 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport org.openjdk.skara.json.JSONValue;\n\n/**\n * Extension of the Issue interface with additional functionality present in a bug\n * tracking system. Extracted to an interface to facilitate test implementations.\n */\npublic interface IssueTrackerIssue extends Issue {\n    List<Link> links();\n\n    void addLink(Link link);\n\n    void removeLink(Link link);\n\n    Map<String, JSONValue> properties();\n\n    void setProperty(String name, JSONValue value);\n\n    void removeProperty(String name);\n\n    /**\n     * @return The raw status name string from the issue tracker\n     */\n    String status();\n\n    /**\n     * @return The raw resolution name string from the issue tracker, or empty\n     * if it hasn't been set.\n     */\n    Optional<String> resolution();\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/Label.java",
    "content": "/*\n * Copyright (c) 2021, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\npackage org.openjdk.skara.issuetracker;\n\nimport java.util.Objects;\nimport java.util.Optional;\n\npublic class Label implements Comparable<Label> {\n    private final String name;\n    private final String description;\n\n    public Label(String name) {\n        this(name, null);\n    }\n\n    public Label(String name, String description) {\n        this.name = name;\n        this.description = description;\n    }\n\n    public String name() {\n        return name;\n    }\n\n    public Optional<String> description() {\n        return Optional.ofNullable(description);\n    }\n\n    @Override\n    public int compareTo(Label l) {\n        return name.compareTo(l.name);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n\n        if (!(o instanceof Label l)) {\n            return false;\n        }\n\n        return Objects.equals(name, l.name) &&\n               Objects.equals(description, l.description);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(name, description);\n    }\n\n    @Override\n    public String toString() {\n        return name;\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/Link.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker;\n\nimport java.net.URI;\nimport java.util.*;\n\npublic class Link {\n    private final URI uri;\n    private final String title;\n    private final String relationship;\n    private final String summary;\n    private final URI iconUrl;\n    private final String iconTitle;\n    private final URI statusIconUrl;\n    private final String statusIconTitle;\n    private final boolean resolved;\n    private final IssueTrackerIssue linked;\n\n    Link(URI uri, String title, String relationship, String summary, URI iconUrl, String iconTitle,\n            URI statusIconUrl, String statusIconTitle, boolean resolved, IssueTrackerIssue linked) {\n        this.uri = uri;\n        this.title = title;\n        this.relationship = relationship;\n        this.summary = summary;\n        this.iconUrl = iconUrl;\n        this.iconTitle = iconTitle;\n        this.statusIconUrl = statusIconUrl;\n        this.statusIconTitle = statusIconTitle;\n        this.resolved = resolved;\n        this.linked = linked;\n    }\n\n    public static WebLinkBuilder create(URI uri, String title) {\n        return new WebLinkBuilder(uri, title);\n    }\n\n    public static IssueLinkBuilder create(IssueTrackerIssue issue, String relationship) {\n        return new IssueLinkBuilder(issue, relationship);\n    }\n\n    public Optional<URI> uri() {\n        return Optional.ofNullable(uri);\n    }\n\n    public Optional<String> title() {\n        return Optional.ofNullable(title);\n    }\n\n    public Optional<IssueTrackerIssue> issue() {\n        return Optional.ofNullable(linked);\n    }\n\n    public Optional<String> relationship() {\n        return Optional.ofNullable(relationship);\n    }\n\n    public Optional<String> summary() {\n        return Optional.ofNullable(summary);\n    }\n\n    public Optional<URI> iconUrl() {\n        return Optional.ofNullable(iconUrl);\n    }\n\n    public Optional<String> iconTitle() {\n        return Optional.ofNullable(iconTitle);\n    }\n\n    public Optional<URI> statusIconUrl() {\n        return Optional.ofNullable(statusIconUrl);\n    }\n\n    public Optional<String> statusIconTitle() {\n        return Optional.ofNullable(statusIconTitle);\n    }\n\n    public boolean resolved() {\n        return resolved;\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        Link link = (Link) o;\n        return resolved == link.resolved &&\n                Objects.equals(uri, link.uri) &&\n                Objects.equals(title, link.title) &&\n                Objects.equals(relationship, link.relationship) &&\n                Objects.equals(linked, link.linked) &&\n                Objects.equals(summary, link.summary) &&\n                Objects.equals(iconUrl, link.iconUrl) &&\n                Objects.equals(iconTitle, link.iconTitle) &&\n                Objects.equals(statusIconUrl, link.statusIconUrl) &&\n                Objects.equals(statusIconTitle, link.statusIconTitle);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(uri, title, relationship, summary, iconUrl, iconTitle, statusIconUrl, statusIconTitle, resolved);\n    }\n\n    @Override\n    public String toString() {\n        return \"Link{\" +\n                \"uri=\" + uri +\n                \", title='\" + title + '\\'' +\n                \", relationship='\" + relationship + '\\'' +\n                \", linked='\" + linked + '\\'' +\n                \", summary='\" + summary + '\\'' +\n                \", iconUrl=\" + iconUrl +\n                \", iconTitle='\" + iconTitle + '\\'' +\n                \", statusIconUrl=\" + statusIconUrl +\n                \", statusIconTitle='\" + statusIconTitle + '\\'' +\n                \", resolved=\" + resolved +\n                '}';\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/WebLinkBuilder.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker;\n\nimport java.net.URI;\n\npublic class WebLinkBuilder {\n    private final URI uri;\n    private final String title;\n\n    private String relationship;\n    private String summary;\n    private URI iconUrl;\n    private String iconTitle;\n    private URI statusIconUrl;\n    private String statusIconTitle;\n    private boolean resolved;\n\n    WebLinkBuilder(URI uri, String title) {\n        this.uri = uri;\n        this.title = title;\n    }\n\n    public WebLinkBuilder relationship(String relationship) {\n        this.relationship = relationship;\n        return this;\n    }\n\n    public WebLinkBuilder summary(String summary) {\n        this.summary = summary;\n        return this;\n    }\n\n    public WebLinkBuilder iconUrl(URI iconUrl) {\n        this.iconUrl = iconUrl;\n        return this;\n    }\n\n    public WebLinkBuilder iconTitle(String iconTitle) {\n        this.iconTitle = iconTitle;\n        return this;\n    }\n\n    public WebLinkBuilder statusIconUrl(URI statusIconUrl) {\n        this.statusIconUrl = statusIconUrl;\n        return this;\n    }\n\n    public WebLinkBuilder statusIconTitle(String statusIconTitle) {\n        this.statusIconTitle = statusIconTitle;\n        return this;\n    }\n\n    public WebLinkBuilder resolved(boolean resolved) {\n        this.resolved = resolved;\n        return this;\n    }\n\n    public Link build() {\n        return new Link(uri, title, relationship, summary, iconUrl, iconTitle, statusIconUrl, statusIconTitle, resolved, null);\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/jira/JiraHost.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker.jira;\n\nimport java.time.Duration;\nimport java.time.ZoneId;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.network.*;\n\nimport java.net.URI;\nimport java.util.*;\n\npublic class JiraHost implements IssueTracker {\n    private static class BackportEndpoint implements CustomEndpoint, CustomEndpointRequest {\n        private final RestRequest request;\n\n        private RestRequest.QueryBuilder query;\n        private JSONValue body;\n\n        private BackportEndpoint(RestRequest request) {\n            this.request = request;\n        }\n\n        @Override\n        public CustomEndpointRequest post() {\n            query = request.post();\n            return this;\n        }\n\n        @Override\n        public CustomEndpointRequest body(JSONValue body) {\n            this.body = body;\n            return this;\n        }\n\n        @Override\n        public CustomEndpointRequest header(String value, String name) {\n            query = query.header(value, name);\n            return this;\n        }\n\n        @Override\n        public CustomEndpointRequest onError(RestRequest.ErrorTransform transform) {\n            query = query.onError(transform);\n            return this;\n        }\n\n        @Override\n        public JSONValue execute() {\n            if (body == null || !body.contains(\"parentIssueKey\")) {\n                throw new IllegalStateException(\"Body must be a JSON object with at least the field 'parentIssueKey' set\");\n            }\n\n            return query.body(body).execute();\n        }\n    }\n\n    private static final String REST_API_ENDPOINT_PATH = \"/rest/api/2/\";\n    private static final String BACKPORT_ENDPOINT_PATH = \"/rest/jbs/1.0/backport/\";\n\n    private final URI uri;\n    private final String visibilityRole;\n    private final RestRequest request;\n    private final RestRequest backportRequest;\n    private final Map<String, IssueProject> issueProjects = new HashMap<>();\n\n    private HostUser currentUser;\n    private ZoneId timeZone;\n\n    JiraHost(URI uri) {\n        this.uri = uri;\n        this.visibilityRole = null;\n\n        var baseApi = URIBuilder.base(uri)\n                                .appendPath(REST_API_ENDPOINT_PATH)\n                                .build();\n        this.request = new RestRequest(baseApi);\n\n        var backportUri = URIBuilder.base(uri)\n                                    .appendPath(BACKPORT_ENDPOINT_PATH)\n                                    .build();\n        this.backportRequest = new RestRequest(backportUri);\n    }\n\n    /**\n     * This constructor is only used by the manual test code.\n     */\n    JiraHost(URI uri, String header, String value) {\n        this.uri = uri;\n        this.visibilityRole = null;\n\n        var baseApi = URIBuilder.base(uri)\n                                .appendPath(REST_API_ENDPOINT_PATH)\n                                .build();\n        this.request = new RestRequest(baseApi, \"test\", (r) -> Arrays.asList(header, value));\n\n        var backportUri = URIBuilder.base(uri)\n                                    .appendPath(BACKPORT_ENDPOINT_PATH)\n                                    .build();\n        this.backportRequest = new RestRequest(backportUri, \"test\", (r) -> Arrays.asList(header, value));\n    }\n\n    JiraHost(URI uri, JiraVault jiraVault) {\n        this(uri, jiraVault, null);\n    }\n\n    JiraHost(URI uri, JiraVault jiraVault, String visibilityRole) {\n        this.uri = uri;\n        this.visibilityRole = visibilityRole;\n\n        var baseApi = URIBuilder.base(uri)\n                                .appendPath(REST_API_ENDPOINT_PATH)\n                                .build();\n        this.request = new RestRequest(baseApi, jiraVault.authId(), (r) -> Arrays.asList(\"Cookie\", jiraVault.getCookie()));\n\n        var backportUri = URIBuilder.base(uri)\n                                    .appendPath(BACKPORT_ENDPOINT_PATH)\n                                    .build();\n        this.backportRequest = new RestRequest(backportUri, jiraVault.authId(), (r) -> Arrays.asList(\"Cookie\", jiraVault.getCookie()));\n    }\n\n    @Override\n    public URI uri() {\n        return uri;\n    }\n\n    @Override\n    public Optional<CustomEndpoint> lookupCustomEndpoint(String path) {\n        var endpoint = switch (path) {\n            case BACKPORT_ENDPOINT_PATH -> new BackportEndpoint(backportRequest);\n            default -> null;\n        };\n        return Optional.ofNullable(endpoint);\n    }\n\n    Optional<String> visibilityRole() {\n        return Optional.ofNullable(visibilityRole);\n    }\n\n    @Override\n    public boolean isValid() {\n        var version = request.get(\"serverInfo\")\n                             .onError(r -> Optional.of(JSON.object().put(\"invalid\", true)))\n                             .execute();\n        return !version.contains(\"invalid\");\n    }\n\n    @Override\n    public IssueProject project(String name) {\n        return issueProjects.computeIfAbsent(name, n -> new JiraProject(this, request, n));\n    }\n\n    @Override\n    public Optional<HostUser> user(String username) {\n        var data = request.get(\"user\")\n                          .param(\"username\", username)\n                          .onError(r -> r.statusCode() == 404 ? Optional.of(JSON.of()) : Optional.empty())\n                          .execute();\n        if (data.isNull()) {\n            return Optional.empty();\n        }\n\n        var user = HostUser.create(data.get(\"name\").asString(),\n                data.get(\"name\").asString(),\n                data.get(\"displayName\").asString(),\n                data.get(\"active\").asBoolean());\n        return Optional.of(user);\n    }\n\n    @Override\n    public HostUser currentUser() {\n        if (currentUser == null) {\n            var data = request.get(\"myself\").execute();\n            currentUser = HostUser.builder()\n                    .id(data.get(\"name\").asString())\n                    .username(data.get(\"name\").asString())\n                    .fullName(data.get(\"displayName\").asString())\n                    .active(data.get(\"active\").asBoolean())\n                    .email(data.get(\"emailAddress\").asString())\n                    .build();\n        }\n        return currentUser;\n    }\n\n    public ZoneId timeZone() {\n        if (timeZone == null) {\n            var data = request.get(\"myself\").execute();\n            timeZone = ZoneId.of(data.get(\"timeZone\").asString());\n        }\n        return timeZone;\n    }\n\n    @Override\n    public boolean isMemberOf(String groupId, HostUser user) {\n        var data = request.get(\"user\")\n                          .param(\"username\", user.id())\n                          .param(\"expand\", \"groups\")\n                          .execute();\n        for (var group : data.get(\"groups\").get(\"items\").asArray()) {\n            if (group.get(\"name\").asString().equals(groupId)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    @Override\n    public String hostname() {\n        return uri.getHost();\n    }\n\n    /**\n     * Jira can only query on timestamps with minute precision.\n     */\n    @Override\n    public Duration timeStampQueryPrecision() {\n        return Duration.ofMinutes(1);\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/jira/JiraIssue.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker.jira;\n\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.network.*;\n\nimport java.net.*;\nimport java.time.ZonedDateTime;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\nimport java.util.logging.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.*;\n\npublic class JiraIssue implements IssueTrackerIssue {\n    private final JiraProject jiraProject;\n    private final RestRequest request;\n    private final JSONValue json;\n    private final boolean needSecurity;\n\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.issuetracker.jira\");\n\n    private static final DateTimeFormatter dateFormat = DateTimeFormatter.ofPattern(\"yyyy-MM-dd'T'HH:mm:ss.SSSZ\");\n    private static final List<String> FILTER_OUT_FIELDS = List.of(\"fields.customfield_11700\");\n    private static final List<String> VALID_RESOLUTIONS = List.of(\"Fixed\", \"Delivered\");\n\n    private List<Label> labels;\n\n    JiraIssue(JiraProject jiraProject, RestRequest request, JSONValue json) {\n        this.jiraProject = jiraProject;\n        this.request = request;\n        this.json = json;\n        this.labels = json.get(\"fields\").get(\"labels\").stream()\n                .map(s -> new Label(s.asString()))\n                .collect(Collectors.toList());\n        this.needSecurity = jiraProject.jiraHost().visibilityRole().isPresent();\n    }\n\n    private enum JiraIssueState {\n        Open,\n        Resolved,\n        Closed\n    }\n\n    @Override\n    public IssueProject project() {\n        return jiraProject;\n    }\n\n    @Override\n    public String id() {\n        return id(json);\n    }\n\n    static String id(JSONValue json) {\n        return json.get(\"key\").asString();\n    }\n\n    @Override\n    public HostUser author() {\n        return HostUser.create(json.get(\"fields\").get(\"creator\").get(\"key\").asString(),\n                               json.get(\"fields\").get(\"creator\").get(\"name\").asString(),\n                               json.get(\"fields\").get(\"creator\").get(\"displayName\").asString());\n    }\n\n    @Override\n    public String title() {\n        return json.get(\"fields\").get(\"summary\").asString().strip();\n    }\n\n    @Override\n    public void setTitle(String title) {\n        if (needSecurity) {\n            log.warning(\"Issue title does not support setting a visibility role - ignoring\");\n            return;\n        }\n\n        var query = JSON.object()\n                        .put(\"fields\", JSON.object()\n                                           .put(\"summary\", title));\n        request.put(\"\").body(query).execute();\n    }\n\n    @Override\n    public String body() {\n        if (json.get(\"fields\").get(\"description\").isNull()) {\n            return \"\";\n        } else {\n            return json.get(\"fields\").get(\"description\").asString();\n        }\n    }\n\n    @Override\n    public void setBody(String body) {\n        if (needSecurity) {\n            log.warning(\"Issue body does not support setting a visibility role - ignoring\");\n            return;\n        }\n\n        var query = JSON.object()\n                        .put(\"fields\", JSON.object()\n                                           .put(\"description\", body));\n        request.put(\"\").body(query).execute();\n    }\n\n    private Comment parseComment(JSONValue json) {\n        return new Comment(json.get(\"id\").asString(),\n                           json.get(\"body\").asString(),\n                           HostUser.create(json.get(\"author\").get(\"name\").asString(),\n                                           json.get(\"author\").get(\"name\").asString(),\n                                           json.get(\"author\").get(\"displayName\").asString()),\n                           ZonedDateTime.parse(json.get(\"created\").asString(), dateFormat),\n                           ZonedDateTime.parse(json.get(\"updated\").asString(), dateFormat));\n    }\n\n    @Override\n    public List<Comment> comments() {\n        var comments = request.get(\"/comment\")\n                              .param(\"maxResults\", \"1000\")\n                              .execute();\n        return comments.get(\"comments\").stream()\n                       .map(this::parseComment)\n                       .collect(Collectors.toList());\n    }\n\n    @Override\n    public Comment addComment(String body) {\n        var query = JSON.object().put(\"body\", body);\n        jiraProject.jiraHost().visibilityRole().ifPresent(visibility -> query.put(\"visibility\", JSON.object()\n                                                                                                    .put(\"type\", \"role\")\n                                                                                                    .put(\"value\", visibility)));\n        var json = request.post(\"/comment\")\n                          .body(query)\n                          .execute();\n        return parseComment(json);\n    }\n\n    @Override\n    public void removeComment(Comment comment) {\n        request.delete(\"/comment/\" + comment.id())\n               .onError(e -> e.statusCode() == 404 ? Optional.of(JSON.object().put(\"already_deleted\", true)) : Optional.empty())\n               .execute();\n    }\n\n    @Override\n    public Comment updateComment(String id, String body) {\n        var query = JSON.object().put(\"body\", body);\n        jiraProject.jiraHost().visibilityRole().ifPresent(visibility -> query.put(\"visibility\", JSON.object()\n                                                                                                    .put(\"type\", \"role\")\n                                                                                                    .put(\"value\", visibility)));\n        var json = request.put(\"/comment/\" + id)\n                          .body(query)\n                          .execute();\n        return parseComment(json);\n    }\n\n    @Override\n    public URI commentUrl(Comment comment) {\n        return URIBuilder.base(webUrl()).appendPath(\"?focusedCommentId=\" + comment.id()).build();\n    }\n\n    @Override\n    public ZonedDateTime createdAt() {\n        return ZonedDateTime.parse(json.get(\"fields\").get(\"created\").asString(), dateFormat);\n    }\n\n    @Override\n    public ZonedDateTime updatedAt() {\n        return ZonedDateTime.parse(json.get(\"fields\").get(\"updated\").asString(), dateFormat);\n    }\n\n    @Override\n    public State state() {\n        switch (json.get(\"fields\").get(\"status\").get(\"name\").asString()) {\n            case \"Closed\":\n                return State.CLOSED;\n            case \"Resolved\":\n                return State.RESOLVED;\n            default:\n                return State.OPEN;\n        }\n    }\n\n    @Override\n    public String status() {\n        return json.get(\"fields\").get(\"status\").get(\"name\").asString();\n    }\n\n    @Override\n    public Optional<String> resolution() {\n        var resolution = json.get(\"fields\").get(\"resolution\");\n        if (resolution != null && !resolution.isNull()) {\n            var name = resolution.get(\"name\");\n            if (name != null && !name.isNull()) {\n                return Optional.of(resolution.get(\"name\").asString());\n            }\n        }\n        return Optional.empty();\n    }\n\n    /**\n     * A Jira issue is considered fixed if it's either resolved or closed and\n     * the resolution is \"Fixed\".\n     */\n    @Override\n    public boolean isFixed() {\n        if (isResolved() || isClosed()) {\n            var resolution = json.get(\"fields\").get(\"resolution\");\n            if (!resolution.isNull()) {\n                return VALID_RESOLUTIONS.contains(resolution.get(\"name\").asString());\n            }\n        }\n        return false;\n    }\n\n    private Map<String, String> availableTransitions() {\n        var transitions = request.get(\"/transitions\").execute();\n        return transitions.get(\"transitions\").stream()\n                          .collect(Collectors.toMap(v -> v.get(\"to\").get(\"name\").asString(),\n                                                    v -> v.get(\"id\").asString()));\n    }\n\n    private void performTransition(JiraIssueState state, Map<String, String> availableTransitions) {\n        var id = availableTransitions.get(state.name());\n        var query = JSON.object()\n                .put(\"transition\", JSON.object()\n                        .put(\"id\", id));\n        if (state == JiraIssueState.Resolved) {\n            query.put(\"fields\", JSON.object()\n                    .put(\"resolution\", JSON.object()\n                            .put(\"name\", \"Fixed\")));\n        }\n        request.post(\"/transitions\")\n                .body(query)\n                .execute();\n    }\n\n    @Override\n    public void setState(State state) {\n        var availableTransitions = availableTransitions();\n\n        if (availableTransitions.isEmpty()) {\n            throw new RuntimeException(\"Available transition states is empty\");\n        }\n\n        // Handle special cases\n        if (state == State.RESOLVED) {\n            if (!availableTransitions.containsKey(JiraIssueState.Resolved.name())) {\n                if (availableTransitions.containsKey(JiraIssueState.Open.name())) {\n                    performTransition(JiraIssueState.Open, availableTransitions);\n                    availableTransitions = availableTransitions();\n                    if (!availableTransitions.containsKey(JiraIssueState.Resolved.name())) {\n                        throw new RuntimeException(\"Cannot transition to Resolved after Open\");\n                    }\n                } else {\n                    // The issue is most likely closed - skip transitioning\n                    log.warning(\"Can't transition the issue to Resolved or Open\");\n                    return;\n                }\n            }\n            performTransition(JiraIssueState.Resolved, availableTransitions);\n        } else if (state == State.CLOSED) {\n            if (!availableTransitions.containsKey(JiraIssueState.Closed.name())) {\n                if (availableTransitions.containsKey(JiraIssueState.Resolved.name())) {\n                    performTransition(JiraIssueState.Resolved, availableTransitions);\n                    availableTransitions = availableTransitions();\n                    if (!availableTransitions.containsKey(JiraIssueState.Closed.name())) {\n                        throw new RuntimeException(\"Cannot transition to Closed after Resolved\");\n                    }\n                } else {\n                    throw new RuntimeException(\"Cannot transition to Closed\");\n                }\n            }\n            performTransition(JiraIssueState.Closed, availableTransitions);\n        } else if (state == State.OPEN) {\n            if (!availableTransitions.containsKey(JiraIssueState.Open.name())) {\n                throw new RuntimeException(\"Cannot transition to Open\");\n            }\n            performTransition(JiraIssueState.Open, availableTransitions);\n        } else {\n            throw new IllegalStateException(\"Unknown state \" + state);\n        }\n    }\n\n    @Override\n    public void addLabel(String label) {\n        labels = null;\n        var query = JSON.object()\n                        .put(\"update\", JSON.object()\n                                           .put(\"labels\", JSON.array().add(JSON.object()\n                                                                               .put(\"add\", label))));\n        request.put(\"\").body(query).execute();\n    }\n\n    @Override\n    public void removeLabel(String label) {\n        labels = null;\n        var query = JSON.object()\n                        .put(\"update\", JSON.object()\n                                           .put(\"labels\", JSON.array().add(JSON.object()\n                                                                               .put(\"remove\", label))));\n        request.put(\"\").body(query).execute();\n    }\n\n    @Override\n    public void setLabels(List<String> labels) {\n        var labelsArray = JSON.array();\n        for (var label : labels) {\n            labelsArray.add(label);\n        }\n        var query = JSON.object()\n                        .put(\"update\", JSON.object()\n                                           .put(\"labels\", JSON.array().add(JSON.object()\n                                                                               .put(\"set\", labelsArray))));\n        request.put(\"\").body(query).execute();\n        this.labels = labels.stream().map(Label::new).collect(Collectors.toList());\n    }\n\n    @Override\n    public List<Label> labels() {\n        if (labels == null) {\n            labels = request.get(\"\").execute().get(\"fields\").get(\"labels\").stream()\n                    .map(s -> new Label(s.asString()))\n                    .collect(Collectors.toList());\n        }\n        return labels;\n    }\n\n    @Override\n    public URI webUrl() {\n        return URIBuilder.base(jiraProject.jiraHost().uri())\n                         .appendPath(\"/browse/\" + id())\n                         .build();\n    }\n\n    @Override\n    public List<HostUser> assignees() {\n        var assignee = json.get(\"fields\").get(\"assignee\");\n        if (assignee.isNull()) {\n            return List.of();\n        }\n\n        var user = HostUser.create(assignee.get(\"name\").asString(),\n                                   assignee.get(\"name\").asString(),\n                                   assignee.get(\"displayName\").asString());\n        return List.of(user);\n    }\n\n    @Override\n    public void setAssignees(List<HostUser> assignees) {\n        String assignee;\n        switch (assignees.size()) {\n            case 0:\n                assignee = null;\n                break;\n            case 1:\n                assignee = assignees.get(0).id();\n                break;\n            default:\n                throw new RuntimeException(\"multiple assignees not supported\");\n        }\n        request.put(\"/assignee\")\n               .body(\"name\", assignee)\n               .execute();\n    }\n\n    private Link parseLink(JSONObject json) {\n        var link = Link.create(URI.create(json.get(\"object\").get(\"url\").asString()), json.get(\"object\").get(\"title\").asString());\n        if (json.contains(\"relationship\")) {\n            link.relationship(json.get(\"relationship\").asString());\n        }\n        if (json.get(\"object\").contains(\"summary\")) {\n            link.summary(json.get(\"object\").get(\"summary\").asString());\n        }\n        if (json.get(\"object\").contains(\"icon\")) {\n            if (json.get(\"object\").get(\"icon\").contains(\"url16x16\")) {\n                link.iconUrl(URI.create(json.get(\"object\").get(\"icon\").get(\"url16x16\").asString()));\n            }\n            if (json.get(\"object\").get(\"icon\").contains(\"title\")) {\n                link.iconTitle(json.get(\"object\").get(\"icon\").get(\"title\").asString());\n            }\n        }\n        if (json.get(\"object\").get(\"status\").contains(\"icon\")) {\n            if (json.get(\"object\").get(\"status\").get(\"icon\").contains(\"url16x16\")) {\n                link.statusIconUrl(URI.create(json.get(\"object\").get(\"status\").get(\"icon\").get(\"url16x16\").asString()));\n            }\n            if (json.get(\"object\").get(\"status\").get(\"icon\").contains(\"title\")) {\n                link.statusIconTitle(json.get(\"object\").get(\"status\").get(\"icon\").get(\"title\").asString());\n            }\n        }\n        link.resolved(json.get(\"object\").get(\"status\").get(\"resolved\").asBoolean());\n        return link.build();\n    }\n\n    @Override\n    public List<Link> links() {\n        var result = new ArrayList<Link>();\n\n        var webLinks = request.get(\"/remotelink\")\n                              .onError(e -> e.statusCode() == 401 ? Optional.of(JSON.array()) : Optional.empty())\n                              .execute()\n                              .stream()\n                              .map(JSONValue::asObject)\n                              .filter(obj -> obj.contains(\"globalId\"))\n                              .filter(obj -> obj.get(\"globalId\").asString().startsWith(\"skaralink=\"))\n                              .map(this::parseLink)\n                              .collect(Collectors.toList());\n        result.addAll(webLinks);\n\n        var commentLinks = comments().stream()\n                                     .map(this::parseWebLinkComment)\n                                     .filter(Optional::isPresent)\n                                     .map(Optional::get)\n                                     .collect(Collectors.toList());\n        result.addAll(commentLinks);\n\n        if (json.get(\"fields\").contains(\"issuelinks\")) {\n            var issueLinks = json.get(\"fields\").get(\"issuelinks\").stream()\n                                 .map(JSONValue::asObject)\n                                 .map(o -> Link.create(o.contains(\"inwardIssue\") ? jiraProject.issue(o.get(\"inwardIssue\").get(\"key\").asString()).orElseThrow() :\n                                                               jiraProject.issue(o.get(\"outwardIssue\").get(\"key\").asString()).orElseThrow(),\n                                                       o.contains(\"inwardIssue\") ? o.get(\"type\").get(\"inward\").asString() :\n                                                               o.get(\"type\").get(\"outward\").asString())\n                                               .build())\n                                 .collect(Collectors.toList());\n            result.addAll(issueLinks);\n        }\n\n        return result;\n    }\n\n    private static final Pattern titlePattern = Pattern.compile(\"^Remote link: (.*)\");\n    private static final Pattern urlPattern = Pattern.compile(\"^URL: (.*)\");\n    private static final Pattern summaryPattern = Pattern.compile(\"^Summary: (.*)\");\n    private static final Pattern relationshipPattern = Pattern.compile(\"^Relationship: (.*)\");\n\n    private Optional<Link> parseWebLinkComment(Comment comment) {\n        var lines = comment.body().lines().collect(Collectors.toList());\n        if ((lines.size() < 2 ) || (lines.size() > 4)) {\n            return Optional.empty();\n        }\n        var titleMatcher = titlePattern.matcher(lines.get(0));\n        var urlMatcher = urlPattern.matcher(lines.get(1));\n        if (!titleMatcher.matches() || !urlMatcher.matches()) {\n            return Optional.empty();\n        }\n        try {\n            var uri = URI.create(urlMatcher.group(1));\n            var linkBuilder = Link.create(uri, titleMatcher.group(1));\n            for (int i = 2; i < lines.size(); ++i) {\n                var line = lines.get(i);\n                var summaryMatcher = summaryPattern.matcher(line);\n                if (summaryMatcher.matches()) {\n                    linkBuilder.summary(summaryMatcher.group(1));\n                }\n                var relationshipMatcher = relationshipPattern.matcher(line);\n                if (relationshipMatcher.matches()) {\n                    linkBuilder.relationship(relationshipMatcher.group(1));\n                }\n            }\n            return Optional.of(linkBuilder.build());\n\n        } catch (IllegalArgumentException e) {\n            log.log(Level.WARNING, \"Invalid link in web link comment: \" + urlMatcher.group(1), e);\n            return Optional.empty();\n        }\n    }\n\n    private void addWebLinkAsComment(Link link) {\n        var alreadyPosted = comments().stream()\n                                      .map(this::parseWebLinkComment)\n                                      .filter(Optional::isPresent)\n                                      .map(Optional::get)\n                                      .anyMatch(l -> l.uri().equals(link.uri()) && l.title().equals(link.title()));\n        if (alreadyPosted) {\n            return;\n        }\n\n        var body = new StringBuilder();\n        body.append(\"Remote link: \").append(link.title().orElseThrow()).append(\"\\n\");\n        body.append(\"URL: \").append(link.uri().orElseThrow().toString()).append(\"\\n\");\n        link.summary().ifPresent(summary -> body.append(\"Summary: \").append(summary).append(\"\\n\"));\n        link.relationship().ifPresent(relationship -> body.append(\"Relationship: \").append(relationship).append(\"\\n\"));\n\n        addComment(body.toString());\n    }\n\n    private void addWebLink(Link link) {\n        if (needSecurity) {\n            addWebLinkAsComment(link);\n            return;\n        }\n\n        var query = JSON.object().put(\"globalId\", \"skaralink=\" + link.uri().orElseThrow().toString());\n        var object = JSON.object().put(\"url\", link.uri().orElseThrow().toString()).put(\"title\", link.title().orElseThrow());\n        var status = JSON.object().put(\"resolved\", link.resolved());\n        var icon = JSON.object();\n        var statusIcon = JSON.object();\n\n        query.put(\"object\", object);\n        object.put(\"icon\", icon);\n        object.put(\"status\", status);\n        status.put(\"icon\", statusIcon);\n\n        link.relationship().ifPresent(relationship -> query.put(\"relationship\", relationship));\n        link.summary().ifPresent(summary -> object.put(\"summary\", summary));\n        link.iconUrl().ifPresent(iconUrl -> icon.put(\"url16x16\", iconUrl.toString()));\n        link.iconTitle().ifPresent(iconTitle -> icon.put(\"title\", iconTitle));\n        link.statusIconUrl().ifPresent(statusIconUrl -> statusIcon.put(\"url16x16\", statusIconUrl.toString()));\n        link.statusIconTitle().ifPresent(statusIconTitle -> statusIcon.put(\"title\", statusIconTitle));\n\n        request.post(\"/remotelink\")\n               .body(query)\n               .execute();\n    }\n\n    private boolean matchLinkType(JiraLinkType jiraLinkType, Link link) {\n        var relationship = link.relationship().orElseThrow().toLowerCase();\n        return (jiraLinkType.inward().toLowerCase().equals(relationship)) ||\n                (jiraLinkType.outward().toLowerCase().equals(relationship));\n    }\n\n    private boolean isOutwardLink(JiraLinkType jiraLinkType, Link link) {\n        var relationship = link.relationship().orElseThrow().toLowerCase();\n        return jiraLinkType.outward().toLowerCase().equals(relationship);\n    }\n\n    private void addIssueLink(Link link) {\n        var linkType = jiraProject.linkTypes().stream()\n                                  .filter(lt -> matchLinkType(lt, link))\n                                  .findAny().orElseThrow();\n\n        var query = JSON.object()\n                        .put(\"type\", JSON.object().put(\"name\", linkType.name()));\n        if (isOutwardLink(linkType, link)) {\n            query.put(\"inwardIssue\", JSON.object().put(\"key\", id()));\n            query.put(\"outwardIssue\", JSON.object().put(\"key\", link.issue().orElseThrow().id()));\n        } else {\n            query.put(\"outwardIssue\", JSON.object().put(\"key\", id()));\n            query.put(\"inwardIssue\", JSON.object().put(\"key\", link.issue().orElseThrow().id()));\n        }\n\n        jiraProject.executeLinkQuery(query);\n    }\n\n    @Override\n    public void addLink(Link link) {\n        if (link.uri().isPresent() && link.title().isPresent()) {\n            addWebLink(link);\n        } else if (link.issue().isPresent() && link.relationship().isPresent()) {\n            addIssueLink(link);\n        } else {\n            throw new IllegalArgumentException(\"Unknown type of link: \" + link);\n        }\n    }\n\n    private void removeWebLink(Link link) {\n        request.delete(\"/remotelink\")\n               .param(\"globalId\", \"skaralink=\" + link.uri().orElseThrow().toString())\n               .onError(e -> e.statusCode() == 404 ? Optional.of(JSON.object().put(\"already_deleted\", true)) : Optional.empty())\n               .execute();\n\n        for (var comment : comments()) {\n            var commentLink = parseWebLinkComment(comment);\n            if (commentLink.isEmpty()) {\n                continue;\n            }\n            if (!commentLink.get().uri().equals(link.uri())) {\n                continue;\n            }\n            request.delete(\"/comment/\" + comment.id()).execute();\n        }\n    }\n\n    private void removeIssueLink(Link link) {\n        throw new RuntimeException(\"not implemented yet\");\n    }\n\n    @Override\n    public void removeLink(Link link) {\n        if (link.uri().isPresent()) {\n            removeWebLink(link);\n        } else if (link.issue().isPresent() && link.relationship().isPresent()) {\n            removeIssueLink(link);\n        } else {\n            throw new IllegalArgumentException(\"Unknown type of link: \" + link);\n        }\n    }\n\n    @Override\n    public Map<String, JSONValue> properties() {\n        var ret = new HashMap<String, JSONValue>();\n\n        for (var field : json.get(\"fields\").asObject().fields()) {\n            var value = field.value();\n            var decoded = jiraProject.decodeProperty(field.name(), value);\n            decoded.ifPresent(jsonValue -> ret.put(field.name(), jsonValue));\n        }\n        return ret;\n    }\n\n    @Override\n    public void setProperty(String name, JSONValue value) {\n        var encoded = jiraProject.encodeProperty(name, value);\n        if (encoded.isEmpty()) {\n            log.warning(\"Ignoring unknown property: \" + name);\n            return;\n        }\n        var customEncoded = jiraProject.encodeCustomFields(name, encoded.get(), properties(), id());\n        var query = JSON.object().put(\"fields\", JSON.object().put(name, customEncoded));\n        request.put(\"\").body(query).execute();\n    }\n\n    @Override\n    public void removeProperty(String name) {\n\n    }\n\n    @Override\n    public Optional<HostUser> closedBy() {\n        throw new RuntimeException(\"Not implemented yet\");\n    }\n\n    /**\n     * Equality for a JiraIssue is based on the data snapshot retrieved when\n     * the instance was created.\n     * Filter out the fields in FILTER_OUT_FIELDS\n     */\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        JiraIssue jiraIssue = (JiraIssue) o;\n        var copiedJson = JSON.parse(json.toString());\n        var copiedJiraIssueJson = JSON.parse(jiraIssue.json.toString());\n        filterOutJSONFields(copiedJson);\n        filterOutJSONFields(copiedJiraIssueJson);\n        return Objects.equals(copiedJson, copiedJiraIssueJson);\n    }\n\n    private void filterOutJSONFields(JSONValue json) {\n        for (String field : FILTER_OUT_FIELDS) {\n            var parts = field.split(\"\\\\.\");\n            var tempJson = json.asObject();\n            var length = parts.length;\n            for (int i = 0; i < length; i++) {\n                if (i != length - 1) {\n                    if (tempJson.contains(parts[i])) {\n                        tempJson = tempJson.get(parts[i]).asObject();\n                    } else {\n                        break;\n                    }\n                } else {\n                    tempJson.remove(parts[i]);\n                }\n            }\n        }\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(json);\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/jira/JiraIssueTrackerFactory.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker.jira;\n\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.JSONObject;\nimport org.openjdk.skara.network.URIBuilder;\n\nimport java.net.URI;\n\npublic class JiraIssueTrackerFactory implements IssueTrackerFactory {\n    @Override\n    public String name() {\n        return \"jira\";\n    }\n\n    @Override\n    public IssueTracker create(URI uri, Credential credential, JSONObject configuration) {\n        if (credential == null) {\n            return new JiraHost(uri);\n        } else {\n            if (credential.username().startsWith(\"https://\")) {\n                var vaultUrl = URIBuilder.base(credential.username()).build();\n                var jiraVault = new JiraVault(vaultUrl, credential.password(), uri);\n\n                if (configuration.contains(\"visibility\")) {\n                    return new JiraHost(uri, jiraVault, configuration.get(\"visibility\").asString());\n                }\n                return new JiraHost(uri, jiraVault);\n            } else {\n                if (credential.username().isEmpty() && !credential.password().isEmpty()) {\n                    // This branch is only used to support test code\n                    return createWithPat(uri, credential.password());\n                } else {\n                    throw new RuntimeException(\"basic authentication not implemented yet\");\n                }\n            }\n        }\n    }\n\n    /**\n     * Get the issue tracker according to the cookie which is copied from the browser.\n     * This method is only used by the manual test code.\n     */\n    public IssueTracker create(URI uri, String cookie) {\n        return new JiraHost(uri, \"Cookie\", cookie);\n    }\n\n    /**\n     * Get the issue tracker according to personal access token.\n     * This method is only used to support to test code (it is\n     * called from a branch in production code that is only\n     * used to support test code).\n     */\n    public IssueTracker createWithPat(URI uri, String pat) {\n        return new JiraHost(uri, \"Authorization\", pat);\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/jira/JiraLinkType.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker.jira;\n\npublic class JiraLinkType {\n    private final String name;\n    private final String inward;\n    private final String outward;\n\n    JiraLinkType(String name, String inward, String outward) {\n        this.name = name;\n        this.inward = inward;\n        this.outward = outward;\n    }\n\n    String name() {\n        return name;\n    }\n\n    String inward() {\n        return inward;\n    }\n\n    String outward() {\n        return outward;\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/jira/JiraProject.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker.jira;\n\nimport java.time.format.DateTimeFormatter;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.network.*;\n\nimport java.net.URI;\nimport java.time.*;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\npublic class JiraProject implements IssueProject {\n\n    public static final String RESOLVED_IN_BUILD = \"customfield_10006\";\n    public static final String SUBCOMPONENT = \"customfield_10008\";\n    public static final String JEP_NUMBER = \"customfield_10701\";\n\n    private final JiraHost jiraHost;\n    private final String projectName;\n    private final RestRequest request;\n\n    private JSONObject projectMetadataCache = null;\n    private List<JiraLinkType> linkTypes = null;\n    private JSONObject createMetaCache = null;\n    private final Map<String, Map<String, JSONObject>> createFieldCache = new HashMap<>();\n    private JSONObject editMetaCache = null;\n\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.issuetracker.jira\");\n\n    JiraProject(JiraHost host, RestRequest request, String projectName) {\n        this.jiraHost = host;\n        this.projectName = projectName;\n        this.request = request;\n    }\n\n    private JSONObject project() {\n        if (projectMetadataCache == null) {\n            projectMetadataCache = request.get(\"project/\" + projectName).execute().asObject();\n        }\n        return projectMetadataCache;\n    }\n\n    private JSONObject createMeta() {\n        if (createMetaCache == null) {\n            createMetaCache = request.get(\"issue/createmeta\")\n                                     .param(\"projectKeys\", projectName)\n                                     .param(\"expand\", \"projects.issuetypes.fields\")\n                                     .execute()\n                                     .asObject();\n        }\n        return createMetaCache;\n    }\n\n    private Map<String, JSONObject> createFields(String issueType) {\n        if (createFieldCache.containsKey(issueType)) {\n            return createFieldCache.get(issueType);\n        }\n        var ret = new HashMap<String, JSONObject>();\n        var fields = request.get(\"issue/createmeta/\" + projectName + \"/issuetypes/\" + issueType)\n                            .onError(et -> et.statusCode() == 404 ? Optional.of((JSON.object().put(\"jira7\", true))) : Optional.empty())\n                            .execute()\n                            .asObject();\n        if (fields.contains(\"jira7\")) {\n            var createMeta = createMeta();\n            fields = createMeta.get(\"projects\").stream()\n                               .filter(p -> p.contains(\"name\"))\n                               .filter(p -> p.get(\"name\").asString().equalsIgnoreCase(projectName))\n                               .findAny().orElseThrow()\n                               .get(\"issuetypes\").stream()\n                               .filter(i -> i.get(\"id\").asString().equals(issueType))\n                               .findAny().orElseThrow()\n                               .get(\"fields\")\n                               .asObject();\n            for (var field : fields.fields()) {\n                ret.put(field.name(), field.value().asObject());\n            }\n        } else {\n            for (var field : fields.get(\"values\").asArray()) {\n                ret.put(field.get(\"fieldId\").asString(), field.asObject());\n            }\n        }\n        createFieldCache.put(issueType, ret);\n        return ret;\n    }\n\n    private JSONObject editMeta(String issueId) {\n        if (editMetaCache == null) {\n            editMetaCache = request.get(\"issue/\" + issueId + \"/editmeta\")\n                                     .execute()\n                                     .asObject();\n        }\n        return editMetaCache;\n    }\n\n    private Map<String, String> issueTypes() {\n        var ret = new TreeMap<String, String>(String.CASE_INSENSITIVE_ORDER);\n        for (var type : project().get(\"issueTypes\").asArray()) {\n            ret.put(type.get(\"name\").asString(), type.get(\"id\").asString());\n        }\n        return ret;\n    }\n\n    private String issueTypeId(String name) {\n        var ret = issueTypes().get(name);\n        if (ret == null) {\n            var allowedList = issueTypes().keySet().stream()\n                                          .map(s -> \"`\" + s + \"`\")\n                                          .collect(Collectors.joining(\", \"));\n            throw new RuntimeException(\"Unknown issue type `\" + name + \"`` Known issue types are \" + allowedList + \".\");\n        }\n        return ret;\n    }\n\n    private Map<String, String> components() {\n        var ret = new HashMap<String, String>();\n        for (var type : project().get(\"components\").asArray()) {\n            ret.put(type.get(\"name\").asString(), type.get(\"id\").asString());\n        }\n        return ret;\n    }\n\n    private String componentId(String name) {\n        var ret = components().get(name);\n        if (ret == null) {\n            var allowedList = components().keySet().stream()\n                                          .map(s -> \"`\" + s + \"`\")\n                                          .collect(Collectors.joining(\", \"));\n            throw new RuntimeException(\"Unknown component `\" + name + \"`. Known components are \" + allowedList + \".\");\n        }\n        return ret;\n    }\n\n    private Map<String, String> versions() {\n        var ret = new HashMap<String, String>();\n        for (var type : project().get(\"versions\").asArray()) {\n            ret.put(type.get(\"name\").asString(), type.get(\"id\").asString());\n        }\n        return ret;\n    }\n\n    private String versionId(String name) {\n        var ret = versions().get(name);\n        if (ret == null) {\n            // Ensure this is not due to a stale cache\n            projectMetadataCache = null;\n            ret = versions().get(name);\n            if (ret == null) {\n                throw new RuntimeException(\"Unknown version `\" + name + \"`\");\n            }\n        }\n        return ret;\n    }\n\n    private void populateLinkTypesIfNeeded() {\n        if (linkTypes != null) {\n            return;\n        }\n\n        linkTypes = request.get(\"issueLinkType\").execute()\n                           .get(\"issueLinkTypes\").stream()\n                           .map(JSONValue::asObject)\n                           .map(o -> new JiraLinkType(o.get(\"name\").asString(),\n                                                      o.get(\"inward\").asString(),\n                                                      o.get(\"outward\").asString()))\n                           .collect(Collectors.toList());\n    }\n\n    List<JiraLinkType> linkTypes() {\n        populateLinkTypesIfNeeded();\n        return linkTypes;\n    }\n\n    void executeLinkQuery(JSONObject query) {\n        request.post(\"issueLink\").body(query).execute();\n    }\n\n    private String projectId() {\n        return project().get(\"id\").asString();\n    }\n\n    private String defaultIssueType() {\n        return issueTypes().values().stream()\n                           .min(Comparator.naturalOrder()).orElseThrow();\n    }\n\n    private String defaultComponent() {\n        return components().values().stream()\n                           .min(Comparator.naturalOrder()).orElseThrow();\n    }\n\n    JiraHost jiraHost() {\n        return jiraHost;\n    }\n\n    private static final Set<String> knownProperties = Set.of(\"issuetype\", \"fixVersions\", \"versions\", \"priority\", \"components\", \"status\");\n    private static final Set<String> readOnlyProperties = Set.of(\"resolution\", \"security\");\n\n    boolean isAllowedProperty(String name, boolean forWrite) {\n        if (knownProperties.contains(name)) {\n            return true;\n        }\n        if (!forWrite && readOnlyProperties.contains(name)) {\n            return true;\n        }\n        return name.startsWith(\"customfield_\");\n    }\n\n    Optional<JSONValue> decodeProperty(String name, JSONValue value) {\n        if (!isAllowedProperty(name, false)) {\n            return Optional.empty();\n        }\n        if (value.isNull()) {\n            return Optional.empty();\n        }\n\n        // Transform known fields to a better representation\n        switch (name) {\n            case \"fixVersions\": // fall-through\n            case \"versions\": // fall-through\n            case \"components\":\n                return Optional.of(new JSONArray(value.stream()\n                                                      .map(obj -> obj.get(\"name\"))\n                                                      .collect(Collectors.toList())));\n            case RESOLVED_IN_BUILD:\n                return Optional.of(JSON.of(value.get(\"value\").asString()));\n            case SUBCOMPONENT:\n                if (value.isString()) {\n                    return Optional.of(value);\n                } // fall-through\n            case \"status\": // fall-through\n            case \"issuetype\":\n                return Optional.of(JSON.of(value.get(\"name\").asString()));\n            case \"priority\": // fall-through\n            case \"security\":\n                return Optional.of(JSON.of(value.get(\"id\").asString()));\n            default:\n                return Optional.of(value);\n        }\n    }\n\n    Optional<JSONValue> encodeProperty(String name, JSONValue value) {\n        if (!isAllowedProperty(name, true)) {\n            return Optional.empty();\n        }\n\n        switch (name) {\n            case \"fixVersions\": // fall-through\n            case \"versions\":\n                return Optional.of(new JSONArray(value.stream()\n                                                      .map(JSONValue::asString)\n                                                      .map(s -> JSON.object().put(\"id\", versionId(s)))\n                                                      .collect(Collectors.toList())));\n            case \"components\":\n                return Optional.of(new JSONArray(value.stream()\n                                                      .map(JSONValue::asString)\n                                                      .map(s -> JSON.object().put(\"id\", componentId(s)))\n                                                      .collect(Collectors.toList())));\n            case \"issuetype\":\n                return Optional.of(JSON.object().put(\"id\", issueTypeId(value.asString())));\n            case \"priority\": // fall-through\n            case \"security\":\n                return Optional.of(JSON.object().put(\"id\", value.asString()));\n            default:\n                return Optional.of(value);\n        }\n    }\n\n    JSONValue encodeCustomFields(String name, JSONValue value, Map<String, JSONValue> allProperties, String forIssue) {\n        if (!name.startsWith(\"customfield_\")) {\n            return value;\n        }\n\n        if (name.equals(RESOLVED_IN_BUILD)) {\n            var editMeta = editMeta(forIssue);\n            var valueToId = editMeta.get(\"fields\").get(name).get(\"allowedValues\").stream()\n                                    .collect(Collectors.toMap(o -> o.get(\"value\").asString(),\n                                                              o -> o.get(\"id\")));\n            return JSON.object().put(\"id\", valueToId.get(value.asString()));\n        }\n\n        if (!name.equals(SUBCOMPONENT)) {\n            if (value.isObject()) {\n                if (value.asObject().contains(\"id\")) {\n                    return value.get(\"id\");\n                } else {\n                    return value;\n                }\n            } else {\n                return value;\n            }\n        }\n\n        var fields = createFields(allProperties.get(\"issuetype\").get(\"id\").asString());\n        var field = fields.get(name);\n        var componentIds = allProperties.get(\"components\").stream()\n                                        .map(c -> c.get(\"id\").asString())\n                                        .map(Integer::parseInt)\n                                        .collect(Collectors.toSet());\n        if (!field.contains(\"allowedValues\")) {\n             return value.get(\"name\");\n        }\n        var allowed = field.get(\"allowedValues\").stream()\n                           .filter(c -> componentIds.contains(c.get(\"id\").asInt()))\n                           .flatMap(c -> c.get(\"subComponents\").stream())\n                           .collect(Collectors.toMap(s -> s.get(\"name\").asString(),\n                                                     s -> s.get(\"id\").asInt()));\n        if (!allowed.containsKey(value.asString())) {\n            var allowedList = allowed.keySet().stream()\n                                     .map(s -> \"`\" + s + \"`\")\n                                     .collect(Collectors.joining(\", \"));\n            throw new RuntimeException(\"Unknown subcomponent `\" + value.asString() + \"`. Known subcomponents are \" +\n                                               allowedList + \".\");\n        }\n\n        return JSON.of(allowed.get(value.asString()));\n    }\n\n    @Override\n    public IssueTracker issueTracker() {\n        return jiraHost;\n    }\n\n    @Override\n    public URI webUrl() {\n        return URIBuilder.base(jiraHost.uri()).appendPath(\"/projects/\" + projectName).build();\n    }\n\n    private boolean isInitialField(String issueType, String name) {\n        var fields = createFields(issueType);\n        return fields.containsKey(name);\n    }\n\n    @Override\n    public IssueTrackerIssue createIssue(String title, List<String> body, Map<String, JSONValue> properties) {\n        var query = JSON.object();\n\n        // Encode optional properties as fields\n        var finalProperties = new HashMap<String, JSONValue>();\n        for (var property : properties.entrySet()) {\n            var encoded = encodeProperty(property.getKey(), property.getValue());\n            if (encoded.isEmpty()) {\n                continue;\n            }\n            finalProperties.put(property.getKey(), encoded.get());\n        }\n\n        // Always override certain fields\n        finalProperties.put(\"project\", JSON.object().put(\"id\", projectId()));\n        finalProperties.put(\"summary\", JSON.of(title));\n        finalProperties.put(\"description\", JSON.of(String.join(\"\\n\", body)));\n\n        // Provide default values for required fields if not present\n        finalProperties.putIfAbsent(\"components\", JSON.array().add(JSON.object().put(\"id\", defaultComponent())));\n        finalProperties.putIfAbsent(\"issuetype\", JSON.object().put(\"id\", defaultIssueType()));\n\n        // Filter out the fields that can be set at creation time\n        var issueType = finalProperties.get(\"issuetype\").get(\"id\").asString();\n        var fields = JSON.object();\n        finalProperties.entrySet().stream()\n                       .filter(entry -> isInitialField(issueType, entry.getKey()))\n                       .forEach(entry -> fields.put(entry.getKey(), encodeCustomFields(entry.getKey(),\n                                                                                       entry.getValue(),\n                                                                                       finalProperties,\n                                                                                       null)));\n        query.put(\"fields\", fields);\n        var data = request.post(\"issue\")\n                          .body(query)\n                          .execute();\n\n        // Apply fields that have to be set later (if any)\n        var id = data.get(\"key\").asString();\n        if (id.indexOf('-') < 0) {\n            id = projectName.toUpperCase() + \"-\" + id;\n        }\n        var finalId = id;\n        var editFields = JSON.object();\n        finalProperties.entrySet().stream()\n                       .filter(entry -> !isInitialField(issueType, entry.getKey()))\n                       .forEach(entry -> editFields.put(entry.getKey(), encodeCustomFields(entry.getKey(),\n                                                                                           entry.getValue(),\n                                                                                           finalProperties,\n                                                                                           finalId)));\n        if (editFields.fields().size() > 0) {\n            var updateQuery = JSON.object().put(\"fields\", editFields);\n            request.put(\"issue/\" + id)\n                   .body(updateQuery)\n                   .execute();\n\n        }\n\n        return issue(data.get(\"key\").asString()).orElseThrow();\n    }\n\n    private RestRequest generateIssueRequest(String id) {\n        return request.restrict(\"issue/\" + id);\n    }\n\n    private RestRequest generateIssueRequest(JSONValue json) {\n        return generateIssueRequest(JiraIssue.id(json));\n    }\n\n    @Override\n    public Optional<IssueTrackerIssue> issue(String id) {\n        if (id.indexOf('-') < 0) {\n            id = projectName.toUpperCase() + \"-\" + id;\n        }\n        var issueRequest = generateIssueRequest(id);\n        final var finalId = id;\n        var issue = issueRequest.get(\"\")\n                                .onError(r -> {\n                                    log.info(\"Getting issue \" + finalId + \" failed with \" + r.statusCode());\n                                    return r.statusCode() < 500 ? Optional.of(JSON.object().put(\"NOT_FOUND\", true)) : Optional.empty();\n                                })\n                                .execute();\n        if (!issue.contains(\"NOT_FOUND\")) {\n            return Optional.of(new JiraIssue(this, issueRequest, issue));\n        } else {\n            return Optional.empty();\n        }\n    }\n\n    @Override\n    public Optional<IssueTrackerIssue> jepIssue(String jepId) {\n        var issues = request.post(\"search\")\n                .body(\"jql\", \"project = \" + projectName + \" AND \\\"JEP Number\\\" ~ \\\"\" + jepId + \"\\\"\")\n                .execute();\n        if (issues.get(\"issues\").asArray().size() == 0) {\n            return Optional.empty();\n        } else {\n            var json = issues.get(\"issues\").asArray().get(0);\n            return Optional.of(new JiraIssue(this, generateIssueRequest(json), json));\n        }\n    }\n\n    @Override\n    public List<IssueTrackerIssue> issues() {\n        var ret = new ArrayList<IssueTrackerIssue>();\n        var issues = request.post(\"search\")\n                            .body(\"jql\", \"project = \" + projectName + \" AND status in (Open, New)\")\n                            .execute();\n        for (var issue : issues.get(\"issues\").asArray()) {\n            ret.add(new JiraIssue(this, generateIssueRequest(issue), issue));\n        }\n        return ret;\n    }\n\n    // Need to sort by updated time in descending order to guarantee that no issues are missed, see SKARA-1962 for details\n    @Override\n    public List<IssueTrackerIssue> issues(ZonedDateTime updatedAfter) {\n        var timeString = toTimeString(updatedAfter);\n        var jql = \"project = \" + projectName + \" AND updated >= '\" + timeString + \"' ORDER BY updated DESC\";\n        return queryIssues(jql);\n    }\n\n\n    // Need to sort by updated time in descending order to guarantee that no issues are missed, see SKARA-1962 for details\n    @Override\n    public List<IssueTrackerIssue> csrIssues(ZonedDateTime updatedAfter) {\n        var timeString = toTimeString(updatedAfter);\n        var jql = \"project = \" + projectName + \" AND updated >= '\" + timeString + \"' AND issuetype = CSR ORDER BY updated DESC\";\n        return queryIssues(jql);\n    }\n\n    @Override\n    public Optional<IssueTrackerIssue> lastUpdatedIssue() {\n        var jql = \"project = \" + projectName + \" ORDER BY updated DESC\";\n        var issues = request.get(\"search\")\n                .param(\"jql\", jql)\n                .param(\"maxResults\", \"1\")\n                .execute();\n        var issuesArray = issues.get(\"issues\").asArray();\n        if (issuesArray.isEmpty()) {\n            return Optional.empty();\n        } else {\n            var json = issuesArray.get(0);\n            return Optional.of(new JiraIssue(this, generateIssueRequest(json), json));\n        }\n    }\n\n    private String toTimeString(ZonedDateTime time) {\n        var timeZoned = time.withZoneSameInstant(jiraHost.timeZone());\n        return timeZoned.format(DateTimeFormatter.ofPattern(\"yyyy/MM/dd HH:mm\"));\n    }\n\n    private List<IssueTrackerIssue> queryIssues(String jql) {\n        var ret = new HashMap<String, IssueTrackerIssue>();\n        int count = 0;\n        var issues = request.get(\"search\")\n                .param(\"jql\", jql)\n                .execute();\n        var startAt = 0;\n        while (issues.get(\"issues\").asArray().size() > 0) {\n            for (var issue : issues.get(\"issues\").asArray()) {\n                ret.put(JiraIssue.id(issue), new JiraIssue(this, generateIssueRequest(issue), issue));\n                count++;\n            }\n\n            if (count < issues.get(\"total\").asInt()) {\n                startAt += issues.get(\"issues\").asArray().size();\n                issues = request.get(\"search\")\n                        .param(\"jql\", jql)\n                        .param(\"startAt\", String.valueOf(startAt))\n                        .execute();\n            } else {\n                break;\n            }\n        }\n\n        return ret.values().stream().toList();\n    }\n\n    @Override\n    public String name() {\n        return projectName.toUpperCase();\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/main/java/org/openjdk/skara/issuetracker/jira/JiraVault.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker.jira;\n\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.network.*;\n\nimport java.net.URI;\nimport java.nio.charset.StandardCharsets;\nimport java.security.*;\nimport java.util.*;\nimport java.util.logging.Logger;\n\nclass JiraVault {\n    private final RestRequest request;\n    private final String authId;\n    private final URI authProbe;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.issuetracker.jira\");\n\n    private String cookie;\n\n    private String checksum(String body) {\n        try {\n            var digest = MessageDigest.getInstance(\"SHA-256\");\n            digest.update(body.getBytes(StandardCharsets.UTF_8));\n            return Base64.getUrlEncoder().encodeToString(digest.digest());\n        } catch (NoSuchAlgorithmException e) {\n            throw new RuntimeException(\"Cannot find SHA-256\");\n        }\n    }\n\n    JiraVault(URI vaultUri, String vaultToken, URI jiraUri) {\n        authId = checksum(vaultToken);\n        request = new RestRequest(vaultUri, authId, (r) -> Arrays.asList(\n                \"X-Vault-Token\", vaultToken\n        ));\n        this.authProbe = URIBuilder.base(jiraUri).appendPath(\"/rest/api/2/myself\").build();\n    }\n\n    String getCookie() {\n        if (cookie != null) {\n            var authProbeRequest = new RestRequest(authProbe, authId, (r) -> Arrays.asList(\"Cookie\", cookie));\n            var res = authProbeRequest.get()\n                    .onError(error -> error.statusCode() >= 400 ? Optional.of(JSON.of(\"AUTH_ERROR\")) : Optional.empty())\n                    .execute();\n            if (res.isObject() && !res.contains(\"AUTH_ERROR\")) {\n                return cookie;\n            }\n        }\n\n        // Renewal time\n        var result = request.get(\"\").execute();\n        cookie = result.get(\"data\").get(\"cookie.name\").asString() + \"=\" + result.get(\"data\").get(\"cookie.value\").asString();\n        log.info(\"Renewed Jira token (\" + cookie + \")\");\n        return cookie;\n    }\n\n    String authId() {\n        return authId;\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/test/java/org/openjdk/skara/issuetracker/IssueProjectPollerTests.java",
    "content": "/*\n * Copyright (c) 2022, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker;\n\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.TestInfo;\nimport org.openjdk.skara.test.HostCredentials;\nimport org.openjdk.skara.test.TestHost;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\npublic class IssueProjectPollerTests {\n\n    @Test\n    void simple(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var issueProject = credentials.getIssueProject();\n            var issuePoller = new IssueProjectPoller(issueProject, Duration.ZERO);\n\n            // Poll with no Issues in the project\n            var issues = issuePoller.updatedIssues();\n            assertEquals(0, issues.size());\n\n            // Poll again without marking as handled\n            issues = issuePoller.updatedIssues();\n            assertEquals(0, issues.size());\n            issuePoller.lastBatchHandled();\n\n            // Create issue and poll for it\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            issues = issuePoller.updatedIssues();\n            assertEquals(1, issues.size());\n\n            // Poll again without marking as handled\n            issues = issuePoller.updatedIssues();\n            assertEquals(1, issues.size());\n            issuePoller.lastBatchHandled();\n\n            // Poll again\n            issues = issuePoller.updatedIssues();\n            assertEquals(0, issues.size());\n            issuePoller.lastBatchHandled();\n\n            // Touch issue and poll again\n            issue1.setBody(\"foo\");\n            issues = issuePoller.updatedIssues();\n            assertEquals(1, issues.size());\n\n            // Poll again without marking as handled\n            issues = issuePoller.updatedIssues();\n            assertEquals(1, issues.size());\n            issuePoller.lastBatchHandled();\n\n            // Poll again\n            issues = issuePoller.updatedIssues();\n            assertEquals(0, issues.size());\n            issuePoller.lastBatchHandled();\n        }\n    }\n\n    @Test\n    void startUpPadding(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var issueProject = credentials.getIssueProject();\n            var issuePoller = new IssueProjectPoller(issueProject, Duration.ofDays(2));\n\n            // Create two issues, one with updatedAt before and one after the startup\n            // padding limit.\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            issue1.store().setLastUpdate(ZonedDateTime.now().minus(Duration.ofDays(1)));\n            var issue2 = credentials.createIssue(issueProject, \"Issue 2\");\n            issue2.store().setLastUpdate(ZonedDateTime.now().minus(Duration.ofDays(3)));\n\n            // First poll should find issue1 but not issue2.\n            var issues = issuePoller.updatedIssues();\n            assertEquals(1, issues.size());\n            assertEquals(issue1.id(), issues.get(0).id());\n        }\n    }\n\n    @Test\n    void timeStampPadding(TestInfo testInfo) throws IOException, InterruptedException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var issueProject = credentials.getIssueProject();\n            var testHost = (TestHost) issueProject.issueTracker();\n            var issuePoller = new IssueProjectPoller(issueProject, Duration.ZERO);\n\n            // Create issue and poll for it\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            var issues = issuePoller.updatedIssues();\n            assertEquals(1, issues.size());\n            issuePoller.lastBatchHandled();\n\n            // Poll again\n            issues = issuePoller.updatedIssues();\n            assertEquals(0, issues.size());\n            issuePoller.lastBatchHandled();\n\n            // Touch issue and poll again\n            issue1.setBody(\"foo\");\n            issues = issuePoller.updatedIssues();\n            assertEquals(1, issues.size());\n            issuePoller.lastBatchHandled();\n\n            // Poll again\n            // Sleep to make it more likely that this and the previous calls to\n            // updatedIssues are far enough apart to trigger padding.\n            Thread.sleep(1);\n            issues = issuePoller.updatedIssues();\n            assertEquals(0, issues.size());\n            // The query should still return the issue\n            assertEquals(1, issuePoller.getCurrentQueryResult().issues().size());\n\n            // The same should happen again until we call lastBatchHandled()\n            issues = issuePoller.updatedIssues();\n            assertEquals(0, issues.size());\n            assertEquals(1, issuePoller.getCurrentQueryResult().issues().size());\n            issuePoller.lastBatchHandled();\n\n            // With padding triggered, no issues should be returned even at the query\n            // level.\n            var lastFoundUpdatedAt = issue1.store().lastUpdate();\n            issues = issuePoller.updatedIssues();\n            assertEquals(0, issues.size());\n            assertTrue(issuePoller.getCurrentQueryResult().issues().isEmpty(),\n                    \"Nothing should have been returned by the query but contained: \"\n                            + issuePoller.getCurrentQueryResult().issues());\n\n            // The same should happen again until we call lastBatchHandled()\n            issues = issuePoller.updatedIssues();\n            assertEquals(0, issues.size());\n            assertTrue(issuePoller.getCurrentQueryResult().issues().isEmpty(),\n                    \"Nothing should have been returned by the query but contained: \"\n                            + issuePoller.getCurrentQueryResult().issues());\n            issuePoller.lastBatchHandled();\n\n            // Update to something just after the lastUpdate + precision and poll\n            // again. Now it should be returned.\n            issue1.store().setLastUpdate(lastFoundUpdatedAt.plus(Duration.ofNanos(3)));\n            issues = issuePoller.updatedIssues();\n            assertEquals(1, issues.size());\n\n            // The same should happen again until we call lastBatchHandled()\n            issues = issuePoller.updatedIssues();\n            assertEquals(1, issues.size());\n            issuePoller.lastBatchHandled();\n        }\n    }\n\n    @Test\n    void retries(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var issueProject = credentials.getIssueProject();\n            var issuePoller = new IssueProjectPoller(issueProject, Duration.ZERO);\n\n            // Create issue\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            var issues = issuePoller.updatedIssues();\n            assertEquals(1, issues.size());\n            issuePoller.lastBatchHandled();\n\n            // Create another PR and mark the first PR for retry\n            var issue2 = credentials.createIssue(issueProject, \"Issue 2\");\n            issuePoller.retryIssue(issue1);\n            issues = issuePoller.updatedIssues();\n            assertEquals(2, issues.size());\n            issuePoller.lastBatchHandled();\n\n            // Poll again, nothing should not be returned\n            issues = issuePoller.updatedIssues();\n            assertEquals(0, issues.size());\n            issuePoller.lastBatchHandled();\n\n            // Just mark a PR for retry\n            issuePoller.retryIssue(issue2);\n            issues = issuePoller.updatedIssues();\n            assertEquals(1, issues.size());\n\n            // Call again without calling .lastBatchHandled, the retry should be included again\n            issues = issuePoller.updatedIssues();\n            assertEquals(1, issues.size());\n            issuePoller.lastBatchHandled();\n\n            // Update PR and add it as retry, only one copy should be returned\n            issue1.addLabel(\"foo\");\n            issuePoller.retryIssue(issue1);\n            issues = issuePoller.updatedIssues();\n            assertEquals(1, issues.size());\n            issuePoller.lastBatchHandled();\n        }\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/test/java/org/openjdk/skara/issuetracker/IssueTrackerTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker;\n\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.test.HostCredentials;\n\nimport org.junit.jupiter.api.*;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass IssueTrackerTests {\n    @Test\n    void isMemberOfNegativeTests(TestInfo info) throws IOException {\n        try (var credentials = new HostCredentials(info)) {\n            var host = credentials.getIssueProject().issueTracker();\n            var madeUpGroupIdThatCannotContainTestMember = \"1234567890\";\n            assertFalse(host.isMemberOf(madeUpGroupIdThatCannotContainTestMember, host.currentUser()));\n        }\n    }\n\n    @Test\n    void simple(TestInfo info) throws IOException {\n        try (var credentials = new HostCredentials(info)) {\n            var project = credentials.getIssueProject();\n\n            var username = project.issueTracker().currentUser().username();\n            var user = project.issueTracker().user(username);\n            assertEquals(username, user.get().username());\n\n            var issue = credentials.createIssue(project, \"Test issue\");\n            issue.setTitle(\"Updated title\");\n            issue.setBody(\"This is now the body\");\n            var comment = issue.addComment(\"This is a comment\");\n            issue.updateComment(comment.id(), \"Now it is updated\");\n            issue.addLabel(\"label\");\n            issue.addLabel(\"another\");\n            issue.removeLabel(\"label\");\n            issue.setAssignees(List.of(project.issueTracker().currentUser()));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"1.0\"));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"1.0\").add(\"2.0\"));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"3.0\"));\n            var updated = project.issue(issue.id()).orElseThrow();\n            assertEquals(List.of(\"another\"), updated.labelNames());\n            assertEquals(1, updated.properties().get(\"fixVersions\").asArray().size());\n            assertEquals(\"3.0\", updated.properties().get(\"fixVersions\").get(0).asString());\n            assertEquals(List.of(project.issueTracker().currentUser()), updated.assignees());\n            assertEquals(1, updated.comments().size());\n            assertEquals(\"Updated title\", updated.title());\n            assertEquals(\"Now it is updated\", updated.comments().get(0).body());\n\n            issue.setState(Issue.State.RESOLVED);\n        }\n    }\n\n    @Test\n    void addLink(TestInfo info) throws IOException {\n        try (var credentials = new HostCredentials(info)) {\n            var project = credentials.getIssueProject();\n\n            var username = project.issueTracker().currentUser().username();\n            var user = project.issueTracker().user(username);\n            assertEquals(username, user.get().username());\n\n            var issue = credentials.createIssue(project, \"Test issue\");\n            issue.setBody(\"This is now the body\");\n            var link = Link.create(URI.create(\"http://www.example.com/abc\"), \"openjdk/skara/13\")\n                           .relationship(\"reviewed in\")\n                           .summary(\"Pull request\")\n                           .iconUrl(URI.create(\"https://bugs.openjdk.org/images/icons/icon-view.png\"))\n                           .iconTitle(\"Review\")\n                           .resolved(true)\n                           .statusIconUrl(URI.create(\"https://bugs.openjdk.org/images/icons/icon-status-done-green.png\"))\n                           .statusIconTitle(\"Ready for integration\")\n                           .build();\n            issue.addLink(link);\n\n            var links = issue.links();\n            assertEquals(1, links.size());\n            assertEquals(link, links.get(0));\n\n            issue.removeLink(link);\n            links = issue.links();\n            assertEquals(0, links.size());\n        }\n    }\n\n    @Test\n    void addIssueLink(TestInfo info) throws IOException {\n        try (var credentials = new HostCredentials(info)) {\n            var project = credentials.getIssueProject();\n\n            var username = project.issueTracker().currentUser().username();\n            var user = project.issueTracker().user(username);\n            assertEquals(username, user.get().username());\n\n            var issue1 = credentials.createIssue(project, \"Test issue\");\n            issue1.setBody(\"This is now the body\");\n\n            var issue2 = credentials.createIssue(project, \"Test issue 2\");\n            var link = Link.create(issue1, \"duplicates\").build();\n            issue2.addLink(link);\n\n            var links = issue2.links();\n            assertEquals(1, links.size());\n            assertEquals(link.relationship(), links.get(0).relationship());\n            assertEquals(link.issue().orElseThrow().id(), links.get(0).issue().orElseThrow().id());\n\n            assertEquals(1, issue1.links().size());\n            var linkFromIssue1 = issue1.links().get(0);\n            issue1.removeLink(linkFromIssue1);\n            links = issue2.links();\n            assertEquals(0, links.size());\n        }\n    }\n}\n"
  },
  {
    "path": "issuetracker/src/test/java/org/openjdk/skara/issuetracker/jira/JiraIntegrationTests.java",
    "content": "/*\n * Copyright (c) 2022, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.issuetracker.jira;\n\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.issuetracker.IssueTracker;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.proxy.HttpProxy;\nimport org.openjdk.skara.test.TestProperties;\nimport org.openjdk.skara.test.EnabledIfTestProperties;\n\nimport java.io.IOException;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.openjdk.skara.issuetracker.jira.JiraProject.JEP_NUMBER;\n\nclass JiraIntegrationTests {\n    private static TestProperties props;\n    private static IssueTracker tracker;\n\n    @BeforeAll\n    static void beforeAll() {\n        props = TestProperties.load();\n        if (props.contains(\"jira.uri\", \"jira.pat\")) {\n            HttpProxy.setup();\n            var uri = URIBuilder.base(props.get(\"jira.uri\")).build();\n            tracker = new JiraIssueTrackerFactory().createWithPat(uri, \"Bearer \" + props.get(\"jira.pat\"));\n        }\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"jira.uri\", \"jira.pat\"})\n    void testJepIssue() {\n        var project = tracker.project(\"JDK\");\n\n        // Test a closed JEP. Note: all the JEPs may be changed to state `Closed` in the end.\n        var closedJepOpt = project.jepIssue(\"421\");\n        assertTrue(closedJepOpt.isPresent());\n        var closedJep = closedJepOpt.get();\n        assertEquals(\"Closed\", closedJep.status());\n        assertEquals(\"Delivered\", closedJep.resolution().orElseThrow());\n        assertEquals(\"JEP\", closedJep.properties().get(\"issuetype\").asString());\n        assertEquals(\"421\", closedJep.properties().get(JEP_NUMBER).asString());\n\n        // Test a non-existing JEP (large JEP number).\n        var nonExistingJepOpt = project.jepIssue(\"100000000000\");\n        assertTrue(nonExistingJepOpt.isEmpty());\n\n        // Test the wrong JEP (number with alphabet).\n        var wrongNumberJepOpt = project.jepIssue(\"JDK-123\");\n        assertTrue(wrongNumberJepOpt.isEmpty());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"jira.uri\", \"jira.pat\", \"jira.project\", \"jira.issue\"})\n    void testClosingIssue() {\n        var project = tracker.project(props.get(\"jira.project\"));\n        var issueId = props.get(\"jira.issue\");\n\n        var issue = project.issue(issueId).orElseThrow();\n        assertNotEquals(Issue.State.CLOSED, issue.state());\n        issue.setState(Issue.State.CLOSED);\n\n        var issue2 = project.issue(issueId).orElseThrow();\n        assertEquals(Issue.State.CLOSED, issue2.state());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"jira.uri\", \"jira.pat\", \"jira.project\", \"jira.issue\"})\n    void testIssueEquals() throws IOException {\n        var project = tracker.project(props.get(\"jira.project\"));\n        var issueId = props.get(\"jira.issue\");\n\n        var issue = project.issue(issueId).orElseThrow();\n        var issue2 = project.issue(issueId).orElseThrow();\n\n        assertEquals(issue, issue2);\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"jira.uri\", \"jira.pat\", \"jira.user.active\"})\n    void testUserActive() {\n        var activeUserId = props.get(\"jira.user.active\");\n        var activeUser = tracker.user(activeUserId).orElseThrow();\n        assertTrue(activeUser.active());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"jira.uri\", \"jira.pat\", \"jira.user.inactive\"})\n    void testUserInactive() {\n        var inactiveUserId = props.get(\"jira.user.inactive\");\n        var inactiveUser = tracker.user(inactiveUserId).orElseThrow();\n        assertFalse(inactiveUser.active());\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"jira.uri\", \"jira.pat\", \"jira.project\", \"jira.issue\"})\n    void testResolutionOfResolvedIssue() throws IOException {\n        var project = tracker.project(props.get(\"jira.project\"));\n        var issueId = props.get(\"jira.issue\");\n\n        var issue = project.issue(issueId).orElseThrow();\n        issue.setState(Issue.State.OPEN);\n        issue.setState(Issue.State.RESOLVED);\n        issue = project.issue(issueId).orElseThrow();\n        assertTrue(issue.resolution().isPresent());\n        assertEquals(\"Fixed\", issue.resolution().get());\n    }\n}\n"
  },
  {
    "path": "jbs/build.gradle",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.jbs'\n    test {\n        requires 'org.openjdk.skara.test'\n        requires 'org.openjdk.skara.network'\n        requires 'org.openjdk.skara.proxy'\n        requires 'org.junit.jupiter.api'\n        opens 'org.openjdk.skara.jbs' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':host')\n    implementation project(':issuetracker')\n    implementation project(':json')\n\n    testImplementation project(':network')\n    testImplementation project(':proxy')\n    testImplementation project(':test')\n}\n\npublishing {\n    publications {\n        args(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "jbs/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.jbs {\n    requires java.net.http;\n    requires java.logging;\n\n    requires org.openjdk.skara.issuetracker;\n    requires org.openjdk.skara.json;\n\n    exports org.openjdk.skara.jbs;\n}\n"
  },
  {
    "path": "jbs/src/main/java/org/openjdk/skara/jbs/Backports.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jbs;\n\nimport org.openjdk.skara.issuetracker.IssueTrackerIssue;\nimport org.openjdk.skara.issuetracker.Link;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.json.JSONValue;\n\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\nimport java.util.stream.*;\n\nimport static org.openjdk.skara.issuetracker.jira.JiraProject.RESOLVED_IN_BUILD;\n\npublic class Backports {\n    private final static Set<String> primaryTypes = Set.of(\"Bug\", \"New Feature\", \"Enhancement\", \"Task\", \"Sub-task\");\n    private final static Logger log = Logger.getLogger(\"org.openjdk.skara.bots.notify\");\n\n    private static final Pattern FEATURE_FAMILY_PATTERN = Pattern.compile(\"^([^\\\\d]*)(\\\\d*)$\");\n\n    private static boolean isPrimaryIssue(IssueTrackerIssue issue) {\n        var properties = issue.properties();\n        if (!properties.containsKey(\"issuetype\")) {\n            throw new RuntimeException(\"Unknown type for issue \" + issue.id());\n        }\n        var type = properties.get(\"issuetype\");\n        return primaryTypes.contains(type.asString());\n    }\n\n    private static boolean isNonScratchVersion(String version) {\n        return !version.startsWith(\"tbd\") && !version.toLowerCase().equals(\"unknown\");\n    }\n\n    public static Set<String> fixVersions(IssueTrackerIssue issue) {\n        if (!issue.properties().containsKey(\"fixVersions\")) {\n            return Set.of();\n        }\n        return issue.properties().get(\"fixVersions\").stream()\n                    .map(JSONValue::asString)\n                    .collect(Collectors.toSet());\n    }\n\n    /**\n     * Returns the single non-scratch fixVersion entry for an issue. If the issue has either none ore more than one,\n     * no version is returned.\n     * @param issue\n     * @return\n     */\n    public static Optional<JdkVersion> mainFixVersion(IssueTrackerIssue issue) {\n        var versionString = fixVersions(issue).stream()\n                                              .filter(Backports::isNonScratchVersion)\n                                              .collect(Collectors.toList());\n        if (versionString.isEmpty()) {\n            return Optional.empty();\n        }\n        if (versionString.size() > 1) {\n            log.warning(\"Issue \" + issue.id() + \" has multiple valid fixVersions - ignoring\");\n            return Optional.empty();\n        }\n        if (issue.properties().containsKey(RESOLVED_IN_BUILD)) {\n            return JdkVersion.parse(versionString.get(0), issue.properties().get(RESOLVED_IN_BUILD).asString());\n        } else {\n            return JdkVersion.parse(versionString.get(0));\n        }\n    }\n\n    /**\n     *  Return the main issue for this backport.\n     *  Harmless when called with the main issue\n     */\n    public static Optional<IssueTrackerIssue> findMainIssue(IssueTrackerIssue issue) {\n        if (isPrimaryIssue(issue)) {\n            return Optional.of(issue);\n        }\n\n        for (var link : issue.links()) {\n            if (link.issue().isPresent() && link.relationship().isPresent()) {\n                if (link.relationship().get().equals(\"backported by\") || link.relationship().get().equals(\"backport of\")) {\n                    var linkedIssue = link.issue().get();\n                    if (isPrimaryIssue(linkedIssue)) {\n                        return Optional.of(linkedIssue);\n                    }\n                }\n            }\n        }\n\n        log.warning(\"Failed to find main issue for \" + issue.id());\n        return Optional.empty();\n    }\n\n    /**\n     * Return true if issueVersion matches fixVersion.\n     */\n    private static boolean matchVersion(JdkVersion issueVersion, JdkVersion fixVersion) {\n        return issueVersion.equals(fixVersion);\n    }\n\n    /**\n     * If fixVersion has a major release of <N>, and opt string of <opt> it matches if the issueVersion equals to <N>-pool-<opt>.\n     */\n    private static boolean matchOptPoolVersion(JdkVersion issueVersion, JdkVersion fixVersion) {\n        // Remove any trailing 'u' from the feature version as that isn't used in *-pool versions\n        var majorVersion = fixVersion.feature().replaceFirst(\"u$\", \"\");\n        if (fixVersion.opt().isPresent()) {\n            var poolSuffix = \"-pool-\" + fixVersion.opt().get();\n            var poolVersion = JdkVersion.parse(majorVersion + poolSuffix);\n            // fixVersion may be something that doesn't parse into a valid pool version\n            if (poolVersion.isPresent()) {\n                return issueVersion.equals(poolVersion.get());\n            }\n        }\n        return false;\n    }\n\n    /**\n     * If fixVersion has a major release of <N>, it matches if the issueVersion equals to <N>-pool.\n     */\n    private static boolean matchPoolVersion(JdkVersion issueVersion, JdkVersion fixVersion) {\n        // Remove any trailing 'u' from the feature version as that isn't used in *-pool versions\n        var majorVersion = fixVersion.feature().replaceFirst(\"u$\", \"\");\n        var poolVersion = JdkVersion.parse(majorVersion + \"-pool\");\n        // fixVersion may be something that doesn't parse into a valid pool version\n        if (poolVersion.isPresent()) {\n            if (issueVersion.equals(poolVersion.get())) {\n                return true;\n            }\n        }\n        var versionMatcher = FEATURE_FAMILY_PATTERN.matcher(majorVersion);\n        if (versionMatcher.matches()) {\n            var numericMajorVersion = versionMatcher.group(2);\n            if (!numericMajorVersion.equals(majorVersion)) {\n                var numericPoolVersion = JdkVersion.parse(numericMajorVersion + \"-pool\");\n                if (numericPoolVersion.isPresent()) {\n                    if (issueVersion.equals(numericPoolVersion.get())) {\n                        return true;\n                    }\n                }\n            }\n        }\n        return false;\n    }\n\n    /**\n     * Return true if issueVersions is empty or contains only scratch values.\n     */\n    private static boolean matchScratchVersion(Set<String> issueVersions) {\n        return issueVersions.stream()\n                .noneMatch(Backports::isNonScratchVersion);\n    }\n\n    /**\n     * Return issue or one of its backports that applies to fixVersion.\n     *\n     * If the main issue       has the correct fixVersion, use it.\n     * If an existing Backport has the correct fixVersion, use it.\n     * If the main issue       has a matching <N>-pool fixVersion, use it.\n     * If an existing Backport has a matching <N>-pool fixVersion, use it.\n     * If the main issue       has a \"scratch\" fixVersion, use it.\n     * If an existing Backport has a \"scratch\" fixVersion, use it.\n     *\n     * Otherwise, create a new Backport.\n     *\n     * A \"scratch\" fixVersion is empty, \"tbd.*\", or \"unknown\".\n     */\n    public static Optional<IssueTrackerIssue> findIssue(IssueTrackerIssue primary, JdkVersion fixVersion) {\n        log.fine(\"Searching for properly versioned issue for primary issue \" + primary.id());\n        var candidates = Stream.concat(Stream.of(primary), findBackports(primary, false).stream()).toList();\n        candidates.forEach(c -> log.fine(\"Candidate: \" + c.id() + \" with versions: \" + String.join(\",\", fixVersions(c))));\n        var matchingVersionIssue = candidates.stream()\n                .filter(i -> mainFixVersion(i).filter(jdkVersion -> matchVersion(jdkVersion, fixVersion)).isPresent())\n                .findFirst();\n        if (matchingVersionIssue.isPresent()) {\n            log.fine(\"Issue \" + matchingVersionIssue.get().id() + \" has a correct fixVersion\");\n            return matchingVersionIssue;\n        }\n\n        var matchingOptPoolVersionIssue = candidates.stream()\n                .filter(i -> mainFixVersion(i).filter(jdkVersion -> matchOptPoolVersion(jdkVersion, fixVersion)).isPresent())\n                .findFirst();\n        if (matchingOptPoolVersionIssue.isPresent()) {\n            log.fine(\"Issue \" + matchingOptPoolVersionIssue.get().id() + \" has a matching opt pool version\");\n            return matchingOptPoolVersionIssue;\n        }\n\n        var matchingPoolVersionIssue = candidates.stream()\n                .filter(i -> mainFixVersion(i).filter(jdkVersion -> matchPoolVersion(jdkVersion, fixVersion)).isPresent())\n                .findFirst();\n        if (matchingPoolVersionIssue.isPresent()) {\n            log.fine(\"Issue \" + matchingPoolVersionIssue.get().id() + \" has a matching pool version\");\n            return matchingPoolVersionIssue;\n        }\n\n        var matchingScratchVersionIssue = candidates.stream()\n                .filter(i -> matchScratchVersion(fixVersions(i)))\n                .findFirst();\n        if (matchingScratchVersionIssue.isPresent()) {\n            log.fine(\"Issue \" + matchingScratchVersionIssue.get().id() + \" has a scratch fixVersion\");\n            return matchingScratchVersionIssue;\n        }\n\n        log.fine(\"No suitable existing issue for \" + primary.id() + \" with version \" + fixVersion + \" found\");\n        return Optional.empty();\n    }\n\n    /**\n     * Returns issue or one of its backports that has a fixVersion matching the\n     * version pattern and is fixed.\n     */\n    public static Optional<IssueTrackerIssue> findFixedIssue(IssueTrackerIssue primary, Pattern versionPattern) {\n        log.fine(\"Searching for fixed issue with fix version matching /\" + versionPattern + \"/ \"\n                + \" for primary issue \" + primary.id());\n        return Stream.concat(Stream.of(primary).filter(IssueTrackerIssue::isFixed), findBackports(primary, true).stream())\n                .filter(i -> mainFixVersion(i).map(v -> versionPattern.matcher(v.raw()).matches()).orElse(false))\n                .findFirst();\n    }\n\n    /**\n     * Find the closest issue from the provided issue list according to the provided fix version.\n     * This method is similar to `findIssue`, but this method can handle all the fix versions of the issue\n     * instead of only the main fix version and can receive an issue list instead of only the primary issue.\n     *\n     * If one of the issues has the correct fix version, use it.\n     * Else, if one of the issues has a matching <N>-pool-<opt> fix version, use it.\n     * Else, if one of the issues has a matching <N>-pool fix version, use it.\n     * Else, if one of the issues has a \"scratch\" fix version, use it.\n     * Otherwise, return empty.\n     *\n     * A \"scratch\" fixVersion is empty, \"tbd.*\", or \"unknown\".\n     */\n    public static Optional<IssueTrackerIssue> findClosestIssue(List<IssueTrackerIssue> issueList, JdkVersion fixVersion) {\n        var matchingVersionIssue = issueList.stream()\n                .filter(issue -> Backports.fixVersions(issue).stream().anyMatch(\n                        v -> JdkVersion.parse(v).filter(jdkVersion -> matchVersion(jdkVersion, fixVersion)).isPresent()))\n                .findFirst();\n        if (matchingVersionIssue.isPresent()) {\n            return matchingVersionIssue;\n        }\n\n        var matchingOptPoolVersionIssue = issueList.stream()\n                .filter(issue -> Backports.fixVersions(issue).stream().anyMatch(\n                        v -> JdkVersion.parse(v).filter(jdkVersion -> matchOptPoolVersion(jdkVersion, fixVersion)).isPresent()))\n                .findFirst();\n        if (matchingOptPoolVersionIssue.isPresent()) {\n            return matchingOptPoolVersionIssue;\n        }\n\n        var matchingPoolVersionIssue = issueList.stream()\n                .filter(issue -> Backports.fixVersions(issue).stream().anyMatch(\n                        v -> JdkVersion.parse(v).filter(jdkVersion -> matchPoolVersion(jdkVersion, fixVersion)).isPresent()))\n                .findFirst();\n        if (matchingPoolVersionIssue.isPresent()) {\n            return matchingPoolVersionIssue;\n        }\n\n        return issueList.stream()\n                .filter(issue -> matchScratchVersion(fixVersions(issue)))\n                .findFirst();\n    }\n\n    /**\n     * Find the right CSR according to the primary issue and the requested version\n     */\n    public static Optional<IssueTrackerIssue> findCsr(IssueTrackerIssue primary, JdkVersion version) {\n        var csrList = new ArrayList<IssueTrackerIssue>();\n        csrLink(primary).flatMap(Link::issue).ifPresent(csrList::add);\n        for (var backportIssue : Backports.findBackports(primary, false)) {\n            csrLink(backportIssue).flatMap(Link::issue).ifPresent(csrList::add);\n        }\n        return findClosestIssue(csrList, version);\n    }\n\n    /**\n     * Find the CSR of the provided issue\n     */\n    public static Optional<Link> csrLink(IssueTrackerIssue issue) {\n        return issue == null ? Optional.empty() : issue.links().stream()\n                .filter(link -> link.relationship().isPresent() && \"csr for\".equals(link.relationship().get())).findAny();\n    }\n\n    public static List<IssueTrackerIssue> findBackports(IssueTrackerIssue primary, boolean fixedOnly) {\n        var links = primary.links();\n        return links.stream()\n                    .filter(l -> l.issue().isPresent())\n                    .filter(l -> l.relationship().isPresent())\n                    .filter(l -> l.relationship().get().equals(\"backported by\"))\n                    .map(l -> l.issue().get())\n                    .filter(i -> !fixedOnly || i.isFixed())\n                    // We used to filter out any issues not of 'backport' type here, but\n                    // Jira allows linking of any issues with a 'backported by' link, so we\n                    // have to accept them, even if it's weird.\n                    .collect(Collectors.toList());\n    }\n\n    /**\n     * Classifies a given version as belonging to one or more release streams.\n     *\n     * For the JDK 7 and 8 release trains, this is determined by the feature version (8 in 8u240 for example)\n     * combined with the build number. Build numbers between 31 and 60 are considered to be part of the bpr stream.\n     *\n     * For JDK 9 and subsequent releases, release streams branch into Oracle and OpenJDK updates after the second\n     * update version is released. Oracle updates that has a patch version are considered to be part of the bpr stream.\n     * @param jdkVersion\n     * @return\n     */\n    private static List<String> releaseStreams(JdkVersion jdkVersion) {\n        List<String> ret = new ArrayList<String>();\n        try {\n            var featureFamilyMatcher = FEATURE_FAMILY_PATTERN.matcher(jdkVersion.feature());\n            if (!featureFamilyMatcher.matches()) {\n                log.warning(\"Cannot parse feature family: \" + jdkVersion.feature());\n                return ret;\n            }\n            var featureFamily = featureFamilyMatcher.group(1);\n            var featureVersion = featureFamilyMatcher.group(2);\n            var numericFeature = Integer.parseInt(featureVersion);\n            if (numericFeature >= 9) {\n                if (jdkVersion.update().isPresent()) {\n                    var numericUpdate = Integer.parseInt(jdkVersion.update().get());\n                    if (numericUpdate == 1 || numericUpdate == 2) {\n                        if (jdkVersion.opt().isPresent() && jdkVersion.opt().get().equals(\"oracle\") && jdkVersion.components().size() > 4) {\n                            ret.add(jdkVersion.feature() + \"+bpr\");\n                        } else if (numericFeature <= 11 && jdkVersion.resolvedInBuild().isPresent()\n                                && jdkVersion.resolvedInBuildNumber() > 30) {\n                            ret.add(jdkVersion.feature() + \"+bpr\");\n                        } else {\n                            ret.add(jdkVersion.feature() + \"+updates-oracle\");\n                            ret.add(jdkVersion.feature() + \"+updates-openjdk\");\n                        }\n                    } else if (numericUpdate > 2) {\n                        if (jdkVersion.opt().isPresent() && jdkVersion.opt().get().equals(\"oracle\")) {\n                            if (jdkVersion.components().size() > 4) {\n                                ret.add(jdkVersion.feature()+ \"+bpr\");\n                            } else if (numericFeature <= 11 && numericUpdate == 3 && jdkVersion.resolvedInBuild().isPresent()\n                                    && jdkVersion.resolvedInBuildNumber() > 30) {\n                                ret.add(jdkVersion.feature()+ \"+bpr\");\n                            } else {\n                                ret.add(jdkVersion.feature() + \"+updates-oracle\");\n                            }\n                        } else {\n                            ret.add(jdkVersion.feature() + \"+updates-openjdk\");\n                        }\n                    }\n                } else {\n                    ret.add(\"features-\" + featureFamily);\n                    ret.add(jdkVersion.feature() + \"+updates-oracle\");\n                    ret.add(jdkVersion.feature() + \"+updates-openjdk\");\n                }\n            } else if (numericFeature == 7 || numericFeature == 8 || numericFeature == 6) {\n                // For update releases, certain ranges of build numbers need special treatment\n                if (bprException(jdkVersion, numericFeature)) {\n                    ret.add(jdkVersion.feature());\n                } else if (jdkVersion.interim().isPresent()) {\n                    var resolvedInBuild = jdkVersion.resolvedInBuild();\n                    if (resolvedInBuild.isPresent()) {\n                        int resolvedInBuildNumber = jdkVersion.resolvedInBuildNumber();\n                        if (resolvedInBuildNumber < 30) {\n                            ret.add(jdkVersion.feature());\n                        } else if (resolvedInBuildNumber < 60) {\n                            ret.add(jdkVersion.feature() + \"+bpr\");\n                        }\n                    } else {\n                        ret.add(jdkVersion.feature());\n                    }\n                } else {\n                    ret.add(jdkVersion.feature());\n                }\n            } else {\n                log.warning(\"Ignoring issue with unknown version: \" + jdkVersion);\n            }\n        } catch (NumberFormatException e) {\n            log.info(\"Cannot determine release streams for version: \" + jdkVersion + \" (\" + e + \")\");\n        }\n        // For any arbitrary opt string that we haven't already handled explicitly,\n        // we let them represent their own respective release streams.\n        if (jdkVersion.opt().isPresent()) {\n            String opt = jdkVersion.opt().get();\n            if (!opt.equals(\"oracle\")) {\n                var plusOpt = \"+\" + opt;\n                ret = ret.stream()\n                        .map(r -> r + plusOpt)\n                        .collect(Collectors.toList());\n            }\n        }\n        return ret;\n    }\n\n    /**\n     * The general BPR rule cannot be applied to releases that have 30 or more actual builds.\n     *\n     * @return true if such a release is identified.\n     */\n    private static boolean bprException(JdkVersion jdkVersion, int numericFeature) {\n        if (jdkVersion.interim().isPresent()) {\n            var numericInterim = Integer.parseInt(jdkVersion.interim().get());\n            if ((numericFeature == 7 && numericInterim == 40) || (numericFeature == 8 && numericInterim == 26)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    // Split the issue list depending on the release stream\n    private static List<List<IssueTrackerIssue>> groupByReleaseStream(List<IssueTrackerIssue> issues) {\n        var streamIssues = new HashMap<String, List<IssueTrackerIssue>>();\n        for (var issue : issues) {\n            var fixVersion = mainFixVersion(issue);\n            if (fixVersion.isEmpty()) {\n                log.info(\"Issue \" + issue.id() + \" does not a fixVersion set - ignoring\");\n                continue;\n            }\n            var streams = releaseStreams(fixVersion.get());\n            for (var stream : streams) {\n                if (!streamIssues.containsKey(stream)) {\n                    streamIssues.put(stream, new ArrayList<>());\n                }\n                streamIssues.get(stream).add(issue);\n            }\n        }\n\n        var ret = new ArrayList<List<IssueTrackerIssue>>();\n        for (var issuesInStream : streamIssues.values()) {\n            if (issuesInStream.size() < 2) {\n                // It's not a release stream unless it has more than one entry\n                continue;\n            }\n            issuesInStream.sort(Comparator.comparing(i -> mainFixVersion(i).orElseThrow()));\n            ret.add(issuesInStream);\n        }\n        return ret;\n    }\n\n    // Certain versions / build numbers have a special meaning, and should be excluded from stream processing\n    private static boolean onExcludeList(IssueTrackerIssue issue) {\n        var fixVersion = mainFixVersion(issue);\n        if (fixVersion.isEmpty()) {\n            return false;\n        }\n\n        var version = fixVersion.get();\n\n        // 8u260 and 8u270 are contingency releases\n        if (version.raw().equals(\"8u260\")) {\n            return true;\n        }\n        if (version.raw().equals(\"8u270\")) {\n            return true;\n        }\n\n        // 8u41 to 8u44 are reserved for JSR maintenance releases\n        if (version.feature().equals(\"8\") && version.interim().isPresent() && Integer.parseInt(version.interim().get()) >= 41 && Integer.parseInt(version.interim().get()) <= 44) {\n            return true;\n        }\n\n        // JEP-322 interim releases (second digit > 0) should be excluded from evaluation\n        var featureFamilyMatcher = FEATURE_FAMILY_PATTERN.matcher(version.feature());\n        if (featureFamilyMatcher.matches()) {\n            var featureVersion = featureFamilyMatcher.group(2);\n            if (featureVersion.length() > 0) {\n                var numericFeature = Integer.parseInt(featureVersion);\n\n                if (numericFeature >= 9 && version.interim().isPresent() && !version.interim().get().equals(\"0\")) {\n                    return true;\n                }\n            }\n        }\n\n        return false;\n    }\n\n    /**\n     * Returns release stream duplicate issue. I.e.\n     * it will contain issues in any given stream if the fix version of the issue *is not* the first\n     * release where the fix has shipped *within that stream*.\n     *\n     * @param related\n     */\n    public static List<IssueTrackerIssue> releaseStreamDuplicates(List<IssueTrackerIssue> related) {\n        var ret = new ArrayList<IssueTrackerIssue>();\n\n        var includedOnly = related.stream()\n                .filter(issue -> !onExcludeList(issue))\n                .collect(Collectors.toList());\n\n        for (var streamIssues : groupByReleaseStream(includedOnly)) {\n            // The first issue may have the label if it was part of another\n            // stream. (e.g. feature release has 14 & 15 where update release\n            // has 15, 15.0.1 & 15.0.2. In this case the label should be\n            // applied to 15, which is the first releases in the 15u stream)\n            // This means we ignore the first issue for the purposes of adding\n            // the label.\n            if (streamIssues.size() > 1) {\n                var rest = streamIssues.subList(1, streamIssues.size());\n                ret.addAll(rest);\n            }\n        }\n\n        return ret;\n    }\n\n    public static IssueTrackerIssue createBackport(IssueTrackerIssue primary, String fixVersion) {\n        return createBackport(primary, fixVersion, null);\n    }\n\n    public static IssueTrackerIssue createBackport(IssueTrackerIssue primary, String fixVersion, String assignee) {\n        return createBackport(primary, fixVersion, assignee, null);\n    }\n\n    public static IssueTrackerIssue createBackport(IssueTrackerIssue primary, String fixVersion, String assignee, String defaultSecurity) {\n        var backportEndpoint = primary.project()\n                                      .issueTracker()\n                                      .lookupCustomEndpoint(\"/rest/jbs/1.0/backport/\")\n                                      .orElseThrow(() ->\n            new IllegalArgumentException(\"Issue tracker does not support backport endpoint\")\n        );\n        var body = JSON.object()\n                       .put(\"parentIssueKey\", primary.id())\n                       .put(\"fixVersion\", fixVersion);\n\n        if (assignee != null) {\n            body = body.put(\"assignee\", assignee);\n        }\n\n        if (primary.properties().containsKey(\"security\")) {\n            body = body.put(\"level\", primary.properties().get(\"security\").asString());\n        } else if (defaultSecurity != null) {\n            body = body.put(\"level\", defaultSecurity);\n        }\n\n        var response = backportEndpoint.post()\n                                       .body(body)\n                                       .execute();\n        var issue = primary.project().issue(response.get(\"key\").asString()).orElseThrow();\n\n        // The backport should not have any labels set - if it does, clear them\n        var labels = issue.labelNames();\n        if (!labels.isEmpty()) {\n            issue.setLabels(List.of());\n        }\n\n        return issue;\n    }\n}\n"
  },
  {
    "path": "jbs/src/main/java/org/openjdk/skara/jbs/BuildCompare.java",
    "content": "/*\n * Copyright (c) 20202, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jbs;\n\nimport java.util.regex.Pattern;\n\npublic class BuildCompare {\n    private static final Pattern buildPattern = Pattern.compile(\"^b(\\\\d+)\");\n\n    // Return the number from a numbered build (e.g., 'b12' -> 12), or -1 if not a numbered build.\n    private static int buildNumber(String build) {\n        var buildMatcher = buildPattern.matcher(build);\n        if (buildMatcher.matches()) {\n            return Integer.parseInt(buildMatcher.group(1));\n        } else {\n            return -1;\n        }\n    }\n\n    // Notable values for \"Resolved in Build\" are 'team', 'master', and numbered builds\n    // (b22).  'team' should not overwrite any value; 'master' should only\n    // overwrite 'team'; numbered builds (b22) should only be overwritten by\n    // lower numbered builds.\n    // The last condition is due to the use of duplicate bugids in jdk update\n    // releases.  A fix could be made in jdk7u10-b02, and also (due to an\n    // escalation or some other urgent need) be fixed in jdk7u8-b04.  At some\n    // later date when jdk7u8 is merged into, say, jdk7u10-b10, without the\n    // last condition the Resolved in Build field would be changed from b02\n    // to b10.\n    public static boolean shouldReplace(String newBuild, String oldBuild) {\n        if (oldBuild == null) {\n            return true;\n        }\n        if (newBuild.equals(oldBuild)) {\n            return false;\n        }\n        if (newBuild.equals(\"team\")) {\n            return false;\n        }\n        if (newBuild.startsWith(\"ma\")) {\n            return oldBuild.equals(\"team\");\n        }\n\n        var oldBuildNumber = buildNumber(oldBuild);\n        var newBuildNumber = buildNumber(newBuild);\n\n        return oldBuildNumber < 0 || (newBuildNumber >= 0 && newBuildNumber < oldBuildNumber);\n    }\n}\n"
  },
  {
    "path": "jbs/src/main/java/org/openjdk/skara/jbs/JdkVersion.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jbs;\n\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class JdkVersion implements Comparable<JdkVersion> {\n    private final String raw;\n    private final List<String> components;\n    private final String opt;\n    private final String build;\n\n    private final static Pattern jdkVersionPattern = Pattern.compile(\"(5\\\\.0|[1-9][0-9]?)(u([0-9]{1,3}))?(?:-(.*))?$\");\n    private final static Pattern hsxVersionPattern = Pattern.compile(\"(hs[1-9][0-9]{1,2})(\\\\.([0-9]{1,3}))?$\");\n    private final static Pattern embVersionPattern = Pattern.compile(\"(emb-[8-9])(u([0-9]{1,3}))?$\");\n    // Accept any lower case letter prefix, such as 'openjdk', 'openjfx' or 'shenandoah'.\n    private final static Pattern prefixVersionPattern = Pattern.compile(\"([a-z]+[1-9][0-9]?)(u([0-9]{1,3}))?$\");\n\n    // Match a version string symbolizing some future, but yet undefined, update of a major version\n    private final static Pattern futureUpdatePattern = Pattern.compile(\"(([a-z])*[1-9][0-9]*u)(-([a-z0-9]+))?$\");\n\n    private final static Pattern prefixPattern = Pattern.compile(\"([a-z]+)([0-9.]+)$\");\n\n    private final static Pattern legacyPrefixPattern = Pattern.compile(\"^([^\\\\d]*)\\\\d+$\");\n\n    private final static Pattern projectRepoPattern = Pattern.compile(\"(repo|branch)-([a-z0-9]*)\");\n\n    private static List<String> splitComponents(String raw) {\n        var finalComponents = new ArrayList<String>();\n\n        // First check for the legacy patterns\n        for (var legacyPattern : List.of(jdkVersionPattern, hsxVersionPattern, embVersionPattern, prefixVersionPattern)) {\n            var legacyMatcher = legacyPattern.matcher(raw);\n            if (legacyMatcher.matches()) {\n                finalComponents.add(legacyMatcher.group(1));\n                if (legacyMatcher.group(3) != null) {\n                    finalComponents.add(legacyMatcher.group(3));\n                }\n                if (legacyMatcher.groupCount() >= 4 && legacyMatcher.group(4) != null) {\n                    finalComponents.add(null);\n                    finalComponents.add(legacyMatcher.group(4));\n                }\n                break;\n            }\n        }\n\n        // Check special placeholder versions\n        if (finalComponents.isEmpty()) {\n            var matcher = futureUpdatePattern.matcher(raw);\n            if (matcher.matches()) {\n                finalComponents.add(matcher.group(1));\n                // Group 4 is the opt field\n                if (matcher.group(4) != null) {\n                    finalComponents.add(null);\n                    finalComponents.add(matcher.group(4));\n                }\n            }\n        }\n\n        // Check for team/project special version\n        if (finalComponents.isEmpty()) {\n            var matcher = projectRepoPattern.matcher(raw);\n            if (matcher.matches()) {\n                finalComponents.add(matcher.group(1));\n                finalComponents.add(matcher.group(2));\n            }\n        }\n\n        // If no legacy match, use the JEP322 scheme\n        if (finalComponents.isEmpty()) {\n            // The input strings here never contain a $PRE string, but the $OPT string\n            // may contain '-' so matching on first '-' is necessary.\n            var optionalStart = raw.indexOf(\"-\");\n            String optional = null;\n            if (optionalStart >= 0) {\n                optional = raw.substring(optionalStart + 1);\n                raw = raw.substring(0, optionalStart);\n            }\n            String prefix = null;\n            if (\"cpu\".equals(optional) && raw.matches(\"[a-z]+\")) {\n                // Special case of *-cpu. This symbolic version has no set numbers\n                finalComponents.add(raw);\n            } else {\n                var prefixMatcher = prefixPattern.matcher(raw);\n                if (prefixMatcher.matches()) {\n                    prefix = prefixMatcher.group(1);\n                    raw = prefixMatcher.group(2);\n                }\n\n                finalComponents.addAll(Arrays.asList(raw.split(\"\\\\.\")));\n\n                // All components except the optional one must be numeric\n                finalComponents.forEach(Integer::parseUnsignedInt);\n\n                if (prefix != null) {\n                    finalComponents.set(0, prefix + finalComponents.get(0));\n                }\n            }\n\n            if (optional != null) {\n                finalComponents.add(null);\n                finalComponents.add(optional);\n            }\n        }\n\n        return finalComponents;\n    }\n\n    private JdkVersion(String raw, String build) {\n        this.raw = raw;\n        this.build = build;\n\n        var rawComponents = splitComponents(raw);\n        components = rawComponents.stream()\n                                  .takeWhile(Objects::nonNull)\n                                  .collect(Collectors.toList());\n        opt = rawComponents.stream()\n                           .dropWhile(Objects::nonNull)\n                           .filter(Objects::nonNull)\n                           .findAny().orElse(null);\n    }\n\n    public static Optional<JdkVersion> parse(String raw) {\n        try {\n            return Optional.of(new JdkVersion(raw, null));\n        } catch (NumberFormatException e) {\n            return Optional.empty();\n        }\n    }\n\n    public static Optional<JdkVersion> parse(String raw, String build) {\n        try {\n            return Optional.of(new JdkVersion(raw, build));\n        } catch (NumberFormatException e) {\n            return Optional.empty();\n        }\n    }\n\n    public List<String> components() {\n        return new ArrayList<>(components);\n    }\n\n    // JEP-322\n    public String feature() {\n        return components.get(0);\n    }\n\n    public Optional<String> interim() {\n        if (components.size() > 1) {\n            return Optional.of(components.get(1));\n        } else {\n            return Optional.empty();\n        }\n    }\n\n    public Optional<String> update() {\n        if (components.size() > 2) {\n            return Optional.of(components.get(2));\n        } else {\n            return Optional.empty();\n        }\n    }\n\n    public Optional<String> patch() {\n        if (components.size() > 3) {\n            return Optional.of(components.get(3));\n        } else {\n            return Optional.empty();\n        }\n    }\n\n    public Optional<String> opt() {\n        return Optional.ofNullable(opt);\n    }\n\n    public Optional<String> resolvedInBuild() {\n        return Optional.ofNullable(build);\n    }\n\n    public String raw() {\n        return raw;\n    }\n\n    // Return the number from a numbered build (e.g., 'b12' -> 12), or -1 if not a numbered build.\n    public int resolvedInBuildNumber() {\n        if (build == null || build.length() < 2 || build.charAt(0) != 'b') {\n            return -1;\n        } else {\n            return Integer.parseInt(build.substring(1));\n        }\n    }\n\n    private String legacyFeaturePrefix() {\n        var legacyPrefixMatcher = legacyPrefixPattern.matcher(feature());\n        if (legacyPrefixMatcher.matches()) {\n            return legacyPrefixMatcher.group(1);\n        } else {\n            return \"\";\n        }\n    }\n\n    @Override\n    public int compareTo(JdkVersion o) {\n        // Filter out the legacy prefix (if they are the same) to enable numerical comparison\n        var prefix = legacyFeaturePrefix();\n        var otherPrefix = o.legacyFeaturePrefix();\n\n        var myComponents = new ArrayList<>(components);\n        var otherComponents = new ArrayList<>(o.components);\n        if (!prefix.isBlank() && prefix.equals(otherPrefix)) {\n            myComponents.set(0, myComponents.get(0).substring(prefix.length()));\n            otherComponents.set(0, otherComponents.get(0).substring(prefix.length()));\n        }\n\n        // Compare element by element, numerically if possible\n        for (int i = 0; i < Math.min(myComponents.size(), otherComponents.size()); ++i) {\n            var elementComparison = 0;\n            var myComponent = myComponents.get(i);\n            var otherComponent = otherComponents.get(i);\n            try {\n                elementComparison = Integer.compare(Integer.parseInt(myComponent), Integer.parseInt(otherComponent));\n            } catch (NumberFormatException e) {\n                elementComparison = myComponent.compareTo(otherComponent);\n            }\n            if (elementComparison != 0) {\n                return elementComparison;\n            }\n        }\n\n        // A version with additional components comes after an otherwise identical one (12.1.1 > 12.1)\n        var sizeDiff = Integer.compare(myComponents.size(), otherComponents.size());\n        if (sizeDiff != 0) {\n            return sizeDiff;\n        }\n\n        // Finally, check the opt part\n        if (opt != null) {\n            if (o.opt == null) {\n                return 1;\n            } else {\n                return opt.compareTo(o.opt);\n            }\n        } else {\n            if (o.opt == null) {\n                return 0;\n            } else {\n                return -1;\n            }\n        }\n    }\n\n    @Override\n    public String toString() {\n        return \"Version{\" +\n                \"raw='\" + raw + '\\'' +\n                '}';\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) return true;\n        if (o == null || getClass() != o.getClass()) return false;\n        JdkVersion jdkVersion = (JdkVersion) o;\n        return raw.equals(jdkVersion.raw);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(raw);\n    }\n}\n"
  },
  {
    "path": "jbs/src/test/java/org/openjdk/skara/jbs/BackportsIntegrationTests.java",
    "content": "/*\n * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jbs;\n\nimport org.openjdk.skara.host.Credential;\nimport org.openjdk.skara.issuetracker.IssueTracker;\nimport org.openjdk.skara.issuetracker.IssueTrackerFactory;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.json.JSONObject;\nimport org.openjdk.skara.json.JSONValue;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.proxy.HttpProxy;\nimport org.openjdk.skara.test.TestProperties;\nimport org.openjdk.skara.test.EnabledIfTestProperties;\n\nimport java.util.*;\n\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\n\nclass BackportsIntegrationTests {\n    private static TestProperties props;\n    private static IssueTracker tracker;\n\n    @BeforeAll\n    static void beforeAll() {\n        props = TestProperties.load();\n        if (props.contains(\"jbs.uri\", \"jbs.pat\")) {\n            var factory = IssueTrackerFactory.getIssueTrackerFactories().stream().filter(f -> f.name().equals(\"jira\")).findFirst();\n            if (factory.isEmpty()) {\n                throw new IllegalStateException(\"'jbs.uri' and 'jbs.pat' has been configured but could not find IssueTrackerFactory for 'jira'\");\n            }\n            HttpProxy.setup();\n            var uri = URIBuilder.base(props.get(\"jbs.uri\")).build();\n            var credential = new Credential(\"\", \"Bearer \" + props.get(\"jbs.pat\"));\n            tracker = factory.get().create(uri, credential, new JSONObject());\n        }\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"jbs.uri\", \"jbs.pat\"})\n    void testBackportCreation() {\n        var project = tracker.project(\"SKARA\");\n        var issue = project.createIssue(\"Issue to backport\", List.of(\"This is just a test issue for testing backport\"), new HashMap<String, JSONValue>());\n\n        var backport = Backports.createBackport(issue, \"1.0\", \"duke\", null);\n        assertEquals(JSON.of(\"Backport\"), backport.properties().get(\"issuetype\"));\n        assertEquals(JSON.array().add(\"1.0\"), backport.properties().get(\"fixVersions\"));\n        assertNotEquals(issue.id(), backport.id());\n\n        var backportOfLink = backport.links().stream().filter(l -> l.relationship().equals(Optional.of(\"backport of\"))).findFirst();\n        assertTrue(backportOfLink.isPresent());\n        assertTrue(backportOfLink.get().issue().isPresent());\n        assertEquals(issue.id(), backportOfLink.get().issue().get().id());\n\n        issue = project.issue(issue.id()).orElseThrow();\n        var backportedByLink = issue.links().stream().filter(l -> l.relationship().equals(Optional.of(\"backported by\"))).findFirst();\n        assertTrue(backportedByLink.isPresent());\n        assertTrue(backportedByLink.get().issue().isPresent());\n        assertEquals(backport.id(), backportedByLink.get().issue().get().id());\n    }\n}\n"
  },
  {
    "path": "jbs/src/test/java/org/openjdk/skara/jbs/BackportsTests.java",
    "content": "/*\n * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jbs;\n\nimport java.util.regex.Pattern;\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.test.HostCredentials;\n\nimport java.io.IOException;\nimport java.util.*;\nimport java.util.stream.Collectors;\nimport org.openjdk.skara.test.TestIssueTrackerIssue;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.openjdk.skara.issuetracker.jira.JiraProject.RESOLVED_IN_BUILD;\n\npublic class BackportsTests {\n    @Test\n    void mainIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var issueProject = credentials.getIssueProject();\n\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            issue1.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n\n            var issue2 = credentials.createIssue(issueProject, \"Issue 2\");\n            issue2.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue1.addLink(Link.create(issue2, \"backported by\").build());\n\n            var issue3 = credentials.createIssue(issueProject, \"Issue 3\");\n            issue3.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue3.addLink(Link.create(issue1, \"backport of\").build());\n\n            assertEquals(issue1.id(), Backports.findMainIssue(issue1).orElseThrow().id());\n            assertEquals(issue1.id(), Backports.findMainIssue(issue2).orElseThrow().id());\n            assertEquals(issue1.id(), Backports.findMainIssue(issue3).orElseThrow().id());\n\n            assertEquals(List.of(issue2.id(), issue3.id()),\n                    Backports.findBackports(issue1, false).stream().map(Issue::id).toList());\n            assertEquals(List.of(), Backports.findBackports(issue1, true));\n        }\n    }\n\n    @Test\n    void noMainIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var issueProject = credentials.getIssueProject();\n\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            issue1.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n\n            var issue2 = credentials.createIssue(issueProject, \"Issue 2\");\n            issue2.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n\n            var issue3 = credentials.createIssue(issueProject, \"Issue 3\");\n            issue3.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue2.addLink(Link.create(issue3, \"backported by\").build());\n\n            assertEquals(issue1.id(), Backports.findMainIssue(issue1).orElseThrow().id());\n            assertEquals(Optional.empty(), Backports.findMainIssue(issue2));\n            assertEquals(Optional.empty(), Backports.findMainIssue(issue3));\n        }\n    }\n\n    @Test\n    void nonBackportLink(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var issueProject = credentials.getIssueProject();\n\n            var issue1 = credentials.createIssue(issueProject, \"Issue 1\");\n            issue1.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n\n            var issue2 = credentials.createIssue(issueProject, \"Issue 2\");\n            issue2.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            issue2.setState(Issue.State.RESOLVED);\n            issue1.addLink(Link.create(issue2, \"duplicates\").build());\n\n            var issue3 = credentials.createIssue(issueProject, \"Issue 3\");\n            issue3.setProperty(\"issuetype\", JSON.of(\"CSR\"));\n            issue3.setState(Issue.State.RESOLVED);\n            issue1.addLink(Link.create(issue3, \"csr for\").build());\n\n            var issue4 = credentials.createIssue(issueProject, \"Issue 4\");\n            issue4.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue4.setState(Issue.State.RESOLVED);\n            issue1.addLink(Link.create(issue4, \"related to\").build());\n\n            assertEquals(issue1.id(), Backports.findMainIssue(issue1).orElseThrow().id());\n            assertEquals(issue2.id(), Backports.findMainIssue(issue2).orElseThrow().id());\n            assertEquals(Optional.empty(), Backports.findMainIssue(issue3));\n            assertEquals(Optional.empty(), Backports.findMainIssue(issue4));\n\n            assertEquals(List.of(), Backports.findBackports(issue1, false));\n        }\n    }\n\n    @Test\n    void findMainVersion(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var issueProject = credentials.getIssueProject();\n            var issue = credentials.createIssue(issueProject, \"Issue\");\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"tbd\"));\n            assertEquals(Optional.empty(), Backports.mainFixVersion(issue));\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"tbd_minor\"));\n            assertEquals(Optional.empty(), Backports.mainFixVersion(issue));\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"unknown\"));\n            assertEquals(Optional.empty(), Backports.mainFixVersion(issue));\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"11.3\"));\n            assertEquals(List.of(\"11\", \"3\"), Backports.mainFixVersion(issue).orElseThrow().components());\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"unknown\").add(\"11.3\"));\n            assertEquals(List.of(\"11\", \"3\"), Backports.mainFixVersion(issue).orElseThrow().components());\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"11.3\").add(\"unknown\"));\n            assertEquals(List.of(\"11\", \"3\"), Backports.mainFixVersion(issue).orElseThrow().components());\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"11.3\").add(\"12.1\"));\n            assertEquals(Optional.empty(), Backports.mainFixVersion(issue));\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"12.1\").add(\"11.3\"));\n            assertEquals(Optional.empty(), Backports.mainFixVersion(issue));\n        }\n    }\n\n    @Test\n    void findIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var issueProject = credentials.getIssueProject();\n            var issue = credentials.createIssue(issueProject, \"Issue\");\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            var backport = credentials.createIssue(issueProject, \"Backport\");\n            backport.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            backport.setState(Issue.State.RESOLVED);\n            issue.addLink(Link.create(backport, \"backported by\").build());\n            var backportFoo = credentials.createIssue(issueProject, \"Backport Foo\");\n            backportFoo.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue.addLink(Link.create(backportFoo, \"backported by\").build());\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"11-pool\"));\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"12-pool\"));\n            backportFoo.setProperty(\"fixVersions\", JSON.array().add(\"12-pool-foo\"));\n            assertEquals(issue.id(), Backports.findIssue(issue, JdkVersion.parse(\"11.1\").orElseThrow()).orElseThrow().id());\n            assertEquals(backport.id(), Backports.findIssue(issue, JdkVersion.parse(\"12.2\").orElseThrow()).orElseThrow().id());\n            assertEquals(backportFoo.id(), Backports.findIssue(issue, JdkVersion.parse(\"12.2-foo\").orElseThrow()).orElseThrow().id());\n            assertEquals(Optional.empty(), Backports.findIssue(issue, JdkVersion.parse(\"13.3\").orElseThrow()));\n            assertEquals(issue.id(), Backports.findIssue(issue, JdkVersion.parse(\"11.1-foo\").orElseThrow()).orElseThrow().id());\n\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"openjfx17-pool\"));\n            assertEquals(backport.id(), Backports.findIssue(issue, JdkVersion.parse(\"openjfx17.0.1\").orElseThrow()).orElseThrow().id());\n\n            // This may not be desired behavior, but this tests illustrates the current behavior\n            // to avoid confusion.\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"17-pool\"));\n            assertEquals(backport.id(), Backports.findIssue(issue, JdkVersion.parse(\"openjfx17.0.1\").orElseThrow()).orElseThrow().id());\n\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"20-pool\"));\n            assertEquals(backport.id(), Backports.findIssue(issue, JdkVersion.parse(\"20u-cpu\").orElseThrow()).orElseThrow().id());\n\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"jfx20-pool\"));\n            assertEquals(backport.id(), Backports.findIssue(issue, JdkVersion.parse(\"jfx20u-cpu\").orElseThrow()).orElseThrow().id());\n\n            backportFoo.setProperty(\"fixVersions\", JSON.array().add(\"8-pool-foo\"));\n            assertEquals(backportFoo.id(), Backports.findIssue(issue, JdkVersion.parse(\"8u333-foo\").orElseThrow()).orElseThrow().id());\n\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"8-pool\"));\n            assertEquals(backport.id(), Backports.findIssue(issue, JdkVersion.parse(\"openjdk8u333\").orElseThrow()).orElseThrow().id());\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"tbd\"));\n            assertEquals(issue.id(), Backports.findIssue(issue, JdkVersion.parse(\"11.1\").orElseThrow()).orElseThrow().id());\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"12.2\"));\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"tbd\"));\n            assertEquals(issue.id(), Backports.findIssue(issue, JdkVersion.parse(\"12.2\").orElseThrow()).orElseThrow().id());\n            assertEquals(backport.id(), Backports.findIssue(issue, JdkVersion.parse(\"11.1\").orElseThrow()).orElseThrow().id());\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"12.2\"));\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"11.1\"));\n            assertEquals(issue.id(), Backports.findIssue(issue, JdkVersion.parse(\"12.2\").orElseThrow()).orElseThrow().id());\n            assertEquals(backport.id(), Backports.findIssue(issue, JdkVersion.parse(\"11.1\").orElseThrow()).orElseThrow().id());\n            assertEquals(Optional.empty(), Backports.findIssue(issue, JdkVersion.parse(\"13.3\").orElseThrow()));\n        }\n    }\n\n    @Test\n    void testFindClosestIssue(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var issueProject = credentials.getIssueProject();\n            var issue = credentials.createIssue(issueProject, \"Issue\");\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            var backport = credentials.createIssue(issueProject, \"Backport\");\n            backport.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            backport.setState(Issue.State.RESOLVED);\n            issue.addLink(Link.create(backport, \"backported by\").build());\n            var backportFoo = credentials.createIssue(issueProject, \"Backport Foo\");\n            backportFoo.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue.addLink(Link.create(backportFoo, \"backported by\").build());\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"8-pool\").add(\"11-pool\"));\n            assertEquals(issue, Backports.findClosestIssue(List.of(issue), JdkVersion.parse(\"openjdk8u432\").orElseThrow()).orElseThrow());\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"11-pool\"));\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"12-pool\"));\n            backportFoo.setProperty(\"fixVersions\", JSON.array().add(\"12-pool-foo\"));\n            assertEquals(issue, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"11.1\").orElseThrow()).orElseThrow());\n            assertEquals(backport, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"12.2\").orElseThrow()).orElseThrow());\n            assertEquals(backportFoo, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"12.2-foo\").orElseThrow()).orElseThrow());\n            assertEquals(Optional.empty(), Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"13.3\").orElseThrow()));\n            assertEquals(issue, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"11.1-foo\").orElseThrow()).orElseThrow());\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"8\").add(\"11-pool\"));\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"8\").add(\"12-pool\"));\n            backportFoo.setProperty(\"fixVersions\", JSON.array().add(\"8\").add(\"12-pool-foo\"));\n            assertEquals(issue, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"11.1\").orElseThrow()).orElseThrow());\n            assertEquals(backport, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"12.2\").orElseThrow()).orElseThrow());\n            assertEquals(backportFoo, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"12.2-foo\").orElseThrow()).orElseThrow());\n            assertEquals(Optional.empty(), Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"13.3\").orElseThrow()));\n            assertEquals(issue, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"11.1-foo\").orElseThrow()).orElseThrow());\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"tbd\"));\n            assertEquals(issue, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"11.1\").orElseThrow()).orElseThrow());\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"8\").add(\"tbd\"));\n            assertEquals(Optional.empty(), Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"11.1\").orElseThrow()));\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"12.2\"));\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"tbd\"));\n            assertEquals(issue, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"12.2\").orElseThrow()).orElseThrow());\n            assertEquals(backport, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"11.1\").orElseThrow()).orElseThrow());\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"8\").add(\"12.2\"));\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"8\").add(\"tbd\"));\n            assertEquals(issue, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"12.2\").orElseThrow()).orElseThrow());\n            assertEquals(Optional.empty(), Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"11.1\").orElseThrow()));\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"12.2\"));\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"11.1\"));\n            assertEquals(issue, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"12.2\").orElseThrow()).orElseThrow());\n            assertEquals(backport, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"11.1\").orElseThrow()).orElseThrow());\n            assertEquals(Optional.empty(), Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"13.3\").orElseThrow()));\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"8\").add(\"12.2\"));\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"8\").add(\"11.1\"));\n            assertEquals(issue, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"12.2\").orElseThrow()).orElseThrow());\n            assertEquals(backport, Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"11.1\").orElseThrow()).orElseThrow());\n            assertEquals(Optional.empty(), Backports.findClosestIssue(List.of(issue, backport, backportFoo), JdkVersion.parse(\"13.3\").orElseThrow()));\n        }\n    }\n\n    private static class BackportManager {\n        private final HostCredentials credentials;\n        private final IssueProject issueProject;\n        private final List<TestIssueTrackerIssue> issues;\n\n        private void setState(IssueTrackerIssue issue, String version) {\n            if (version.endsWith(\"#open\")) {\n                version = version.substring(0, version.length() - 5);\n            } else if (version.endsWith(\"#wontfix\")) {\n                version = version.substring(0, version.length() - 8);\n                issue.setState(Issue.State.RESOLVED);\n                issue.setProperty(\"resolution\", JSON.object().put(\"name\", JSON.of(\"Won't Fix\")));\n            } else {\n                issue.setState(Issue.State.RESOLVED);\n            }\n\n            var resolvedInBuild = \"\";\n            if (version.contains(\"/\")) {\n                resolvedInBuild = version.split(\"/\", 2)[1];\n                version = version.split(\"/\", 2)[0];\n            }\n            issue.setProperty(\"fixVersions\", JSON.array().add(version));\n            if (!resolvedInBuild.isEmpty()) {\n                issue.setProperty(RESOLVED_IN_BUILD, JSON.of(resolvedInBuild));\n            }\n        }\n\n        BackportManager(HostCredentials credentials, String initialVersion) {\n            this.credentials = credentials;\n            issueProject = credentials.getIssueProject();\n            issues = new ArrayList<>();\n\n            issues.add(credentials.createIssue(issueProject, \"Main issue\"));\n            issues.get(0).setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            setState(issues.get(0), initialVersion);\n        }\n\n        void addBackports(String... versions) {\n            for (int backportIndex = 0; backportIndex < versions.length; ++backportIndex) {\n                var issue = credentials.createIssue(issueProject, \"Backport issue \" + backportIndex);\n                issue.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n                setState(issue, versions[backportIndex]);\n                issues.get(0).addLink(Link.create(issue, \"backported by\").build());\n                issues.add(issue);\n            }\n        }\n\n        void assertLabeled(String... labeledVersions) {\n            var related = Backports.findBackports(issues.get(0), true);\n            var allIssues = new ArrayList<IssueTrackerIssue>();\n            allIssues.add(issues.get(0));\n            allIssues.addAll(related);\n            var labeledIssues = Backports.releaseStreamDuplicates(allIssues)\n                                         .stream()\n                                         .map(issue -> issue.properties().get(\"fixVersions\").get(0).asString())\n                                         .collect(Collectors.toSet());\n            var labels = new HashSet<>(Arrays.asList(labeledVersions));\n            assertEquals(labels, labeledIssues);\n        }\n\n        void assertNotLabeled(String... labeledVersions) {\n            var related = Backports.findBackports(issues.get(0), true);\n            var allIssues = new ArrayList<IssueTrackerIssue>();\n            allIssues.add(issues.get(0));\n            allIssues.addAll(related);\n            var labeledIssues = Backports.releaseStreamDuplicates(allIssues)\n                                         .stream()\n                                         .map(issue -> issue.properties().get(\"fixVersions\").get(0).asString())\n                                         .collect(Collectors.toSet());\n            var unLabeledIssues = new HashSet<>();\n            for (var issue : allIssues) {\n                unLabeledIssues.add(issue.properties().get(\"fixVersions\").get(0).asString());\n            }\n            unLabeledIssues.removeAll(labeledIssues);\n            var labels = new HashSet<>(Arrays.asList(labeledVersions));\n            assertEquals(labels, unLabeledIssues);\n        }\n    }\n\n    @Test\n    void labelFeatureReleaseStream(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"15\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"14\", \"16\");\n            backports.assertLabeled(\"15\", \"16\");\n        }\n    }\n\n    @Test\n    void labelOpenJfxFeatureReleaseStream(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"openjfx15\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"openjfx14\", \"openjfx16\");\n            backports.assertLabeled(\"openjfx15\", \"openjfx16\");\n        }\n    }\n\n    @Test\n    void labelOpenJdkFeatureReleaseStream(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"openjdk8u292\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"openjdk8u302\");\n            backports.assertLabeled(\"openjdk8u302\");\n        }\n    }\n\n    @Test\n    void labelUpdateReleaseStream(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"14\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"14.0.1\", \"14.0.2\");\n            backports.assertLabeled(\"14.0.1\", \"14.0.2\");\n\n            backports.addBackports(\"15\", \"15.0.1\", \"15.0.2\");\n            backports.assertLabeled(\"14.0.1\", \"14.0.2\", \"15\", \"15.0.1\", \"15.0.2\");\n        }\n    }\n\n    @Test\n    void labelOpenJdkUpdateReleaseStream(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"11\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"11.0.1\", \"11.0.2\");\n            backports.assertLabeled(\"11.0.1\", \"11.0.2\");\n\n            backports.addBackports(\"11.0.3\", \"11.0.3-oracle\");\n            backports.assertLabeled(\"11.0.1\", \"11.0.2\", \"11.0.3\", \"11.0.3-oracle\");\n        }\n    }\n\n    @Test\n    void labelSeparateOpenJdkUpdateReleaseStream(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"11.0.3\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"11.0.3-oracle\", \"11.0.4\", \"11.0.4-oracle\");\n            backports.assertLabeled(\"11.0.4\", \"11.0.4-oracle\");\n            backports.assertNotLabeled(\"11.0.3-oracle\", \"11.0.3\");\n        }\n    }\n\n\n    @Test\n    void labelBprStream8(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8u251\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u241/b31\");\n            backports.assertLabeled();\n        }\n    }\n\n    @Test\n    void labelBprStream11(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"11\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"11.0.7.0.3-oracle\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"11.0.8.0.1-oracle\", \"12.0.3.0.1-oracle\");\n            backports.assertLabeled(\"11.0.8.0.1-oracle\");\n        }\n    }\n\n    @Test\n    void labelTest8229219(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"13/b33\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"14/b10\");\n            backports.assertLabeled(\"14\");\n\n            backports.addBackports(\"13.0.1/b06\", \"13.0.2/b01\");\n            backports.assertLabeled(\"14\", \"13.0.1\", \"13.0.2\");\n        }\n    }\n\n    @Test\n    void labelTest8244004(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8u271/master\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u251/b34\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u260/master\", \"8u261/b06\");\n            backports.assertLabeled( \"8u271\");\n        }\n    }\n\n    @Test\n    void labelTest8077707(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"9/b78\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"emb-9/team\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"openjdk8u242/team\", \"openjdk8u232/master\");\n            backports.assertLabeled(\"openjdk8u242\");\n\n            backports.addBackports(\"8u261/b04\", \"8u251/b01\", \"8u241/b31\", \"8u231/b34\");\n            backports.assertLabeled(\"openjdk8u242\", \"8u261\", \"8u241\");\n\n            backports.addBackports(\"emb-8u251/team\", \"7u261/b01\");\n            backports.assertLabeled(\"openjdk8u242\", \"8u261\", \"8u241\");\n        }\n    }\n\n    @Test\n    void labelTest8239803(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"openjfx15\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u261/b01\", \"8u251/b31\", \"8u241/b33\");\n            backports.assertLabeled(\"8u251\");\n        }\n    }\n\n    @Test\n    void labelTest7092821(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"12/b24\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"13/team\", \"11.0.8-oracle/b01\", \"11.0.7/b02\");\n            backports.assertLabeled(\"13\");\n\n            backports.addBackports(\"8u261/b01\", \"8u251/b33\", \"8u241/b61\");\n            backports.assertLabeled(\"13\");\n        }\n    }\n\n    @Test\n    void labelTest8222913(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"13\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"11.0.6-oracle\");\n\n            backports.addBackports(\"11.0.5.0.1-oracle\", \"11.0.5-oracle\", \"11.0.5\");\n            backports.assertLabeled(\"11.0.6-oracle\");\n\n            backports.addBackports(\"11.0.4.0.1-oracle\", \"11.0.4-oracle\", \"11.0.4\");\n            backports.assertLabeled(\"11.0.6-oracle\", \"11.0.5.0.1-oracle\", \"11.0.5-oracle\", \"11.0.5\");\n\n            backports.addBackports(\"11.0.3.0.1-oracle\");\n            backports.assertLabeled(\"11.0.4.0.1-oracle\", \"11.0.6-oracle\", \"11.0.5.0.1-oracle\", \"11.0.5-oracle\", \"11.0.5\");\n        }\n    }\n\n    @Test\n    void labelTest8255226(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"16\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"15-pool#open\", \"11-pool#open\", \"8-pool#open\", \"7-pool#open\");\n            backports.assertLabeled();\n        }\n    }\n\n    @Test\n    void labelTest8242283(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"15\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"14.0.2\", \"14u-cpu\", \"11.0.9-oracle\", \"11.0.9\");\n            backports.assertLabeled();\n        }\n    }\n\n    @Test\n    void labelTest8261303(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"openjfx17\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u271/b33\", \"8u291\", \"8u301\");\n            backports.assertLabeled(\"8u301\");\n\n            backports.addBackports(\"11.0.11-oracle\", \"11.0.11\", \"11.0.10-oracle\", \"11.0.9.0.1-oracle/b01\",\n                    \"11.0.9-oracle\", \"11.0.8.0.2-oracle\");\n            backports.assertLabeled(\"8u301\", \"11.0.9.0.1-oracle\", \"11.0.10-oracle\", \"11.0.11-oracle\");\n        }\n    }\n\n    @Test\n    void sampleTest1(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"16\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"16.0.1\", \"16.0.2\");\n            backports.assertLabeled(\"16.0.1\", \"16.0.2\");\n        }\n    }\n\n    @Test\n    void sampleTest2(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"17\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"16\", \"16.0.1\");\n            backports.assertLabeled(\"17\", \"16.0.1\");\n        }\n    }\n\n    @Test\n    void sampleTest3(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"17\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"16u-cpu\", \"16.0.1\", \"16.0.2\");\n            backports.assertLabeled(\"16.0.2\");\n        }\n    }\n\n    @Test\n    void sampleTest4(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"16\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"16.0.1\", \"16.0.2\", \"17\");\n            backports.assertLabeled(\"16.0.1\", \"16.0.2\", \"17\");\n        }\n    }\n\n    @Test\n    void sampleTest5(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"18\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"17.0.2\", \"17.0.3-oracle\");\n            backports.assertLabeled(\"17.0.3-oracle\");\n        }\n    }\n\n    @Test\n    void sampleTest6(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8u291\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u281\", \"8u271/b34\", \"8u261/b32\");\n            backports.assertLabeled(\"8u291\", \"8u271\");\n        }\n    }\n\n    @Test\n    void sampleTest7(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8u291\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u281/b31\", \"8u271/b60\", \"7u301\");\n            backports.assertLabeled();\n        }\n    }\n\n    @Test\n    void sampleTest8(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8u291\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u281/b31\", \"8u271/b37\", \"openjdk8u292\");\n            backports.assertLabeled(\"8u281\");\n        }\n    }\n\n    @Test\n    void sampleTest9(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8u260\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u261\", \"8u271\");\n            backports.assertLabeled(\"8u271\");\n        }\n    }\n\n    @Test\n    void sampleTest10(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8u261\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u271\", \"8u281\", \"emb-8u271\", \"emb-8u281\");\n            backports.assertLabeled(\"8u271\", \"8u281\", \"emb-8u281\");\n        }\n    }\n\n    @Test\n    void sampleTest11(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8u271/b35\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u281/b31\", \"8u291\", \"openjdk8u292\");\n            backports.assertLabeled(\"8u281\");\n        }\n    }\n\n    @Test\n    void sampleTest12(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8u261\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u271\", \"openjdk8u275\", \"openjdk8u292\", \"emb-8u271\");\n            backports.assertLabeled(\"8u271\", \"openjdk8u292\");\n        }\n    }\n\n    @Test\n    void sampleTest13(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8u261\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u-tls13-repo\", \"8u271\", \"emb-8u261\");\n            backports.assertLabeled(\"8u271\");\n        }\n    }\n\n    @Test\n    void sampleTest14(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8u41\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u261\", \"8u251\");\n            backports.assertLabeled(\"8u261\");\n        }\n    }\n\n    @Test\n    void sampleTest15(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8u291\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u301\", \"8u281/b31\");\n            backports.assertLabeled(\"8u301\");\n        }\n    }\n\n    @Test\n    void sampleTest16(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8u301\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u293\");\n            backports.assertLabeled(\"8u301\");\n        }\n    }\n\n    @Test\n    void sampleTest17(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"11.0.11\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"11.0.11-oracle\");\n            backports.assertLabeled();\n        }\n    }\n\n    @Test\n    void sampleTest18(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"11.0.9\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"11.0.10-oracle\");\n            backports.assertLabeled();\n        }\n    }\n\n    @Test\n    void sampleTest19(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"11.0.12-oracle\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"11.0.13-oracle\", \"11.0.11.0.1-oracle\");\n            backports.assertLabeled(\"11.0.13-oracle\");\n        }\n    }\n\n    @Test\n    void sampleTest20(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"11.0.13-oracle\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"11.0.14-oracle\", \"11.0.12.1-oracle\");\n            backports.assertLabeled(\"11.0.13-oracle\", \"11.0.14-oracle\");\n        }\n    }\n\n    @Test\n    void sampleTest21(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"11.0.13-oracle\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"11.0.14-oracle\", \"11.1.2\");\n            backports.assertLabeled(\"11.0.14-oracle\");\n        }\n    }\n\n    @Test\n    void sampleTest22(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"15\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"14.0.1\", \"14\", \"13.0.2\", \"11.0.7\", \"11.0.6-oracle\", \"11.0.6\", \"openjdk8u252\", \"openjdk8u242\", \"8u241\");\n            backports.assertLabeled(\"15\", \"14.0.1\", \"11.0.7\", \"openjdk8u252\");\n        }\n    }\n\n    @Test\n    void sampleTest23(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"17\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"11.0.11-oracle\", \"11.0.11\", \"8u291\", \"8u281/b31\", \"8u271/b60\", \"emb-8u291\", \"7u301\");\n            backports.assertLabeled();\n        }\n    }\n\n    @Test\n    void sampleTest24(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"15\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"14u-cpu\", \"14.0.2\", \"13.0.7\", \"11.0.9-oracle\", \"11.0.9\");\n            backports.assertLabeled();\n        }\n    }\n\n    @Test\n    void wontFix(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"14\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"15\", \"13.0.7\", \"11.0.12-oracle\", \"11.0.11-oracle#wontfix\", \"11.0.10\");\n            backports.assertLabeled(\"15\");\n        }\n    }\n\n    @Test\n    void testBpr(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"17\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"16.0.2\", \"16.0.1.0.1-oracle\", \"11.0.12-oracle\", \"11.0.11.0.1-oracle\", \"8u301\", \"8u291\", \"8u281\");\n            backports.assertLabeled(\"8u291\", \"8u301\");\n        }\n    }\n\n    @Test\n    void test8u270(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8u270\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u271\", \"8u281\");\n            backports.assertLabeled(\"8u281\");\n        }\n    }\n\n    /**\n     * Verify that a release such as 16u-cpu does not ever get labeled.\n     */\n    @Test\n    void uCpu(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"17\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"16\", \"16.0.2\", \"16u-cpu\");\n            backports.assertLabeled(\"16.0.2\", \"17\");\n        }\n    }\n\n    /**\n     * Verify that the special jdk-cpu version does not ever get labeled.\n     */\n    @Test\n    void cpu(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"17\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"16\", \"16.0.2\", \"jdk-cpu\");\n            backports.assertLabeled(\"16.0.2\", \"17\");\n        }\n    }\n\n    /**\n     * Verify that repo-project versions do not get labeled.\n     */\n    @Test\n    void repoProject(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"17\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"16\", \"16.0.2\", \"repo-foo\");\n            backports.assertLabeled(\"16.0.2\", \"17\");\n        }\n    }\n\n    @Test\n    void openjdk7u(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"17\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"openjdk7u\", \"7u321\", \"openjdk8u302\", \"openjdk8u312\");\n            backports.assertLabeled(\"openjdk8u312\");\n        }\n    }\n\n    @Test\n    void jdk7u40(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"17\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"7u40/b31\", \"7u45\", \"8u60\");\n            backports.assertLabeled(\"7u45\");\n        }\n    }\n\n    @Test\n    void jdk8u26(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"17\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u26/b31\", \"8u30\");\n            backports.assertLabeled(\"8u30\");\n        }\n    }\n\n    @Test\n    void bpr7and8(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"17\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u291\", \"8u281/b30\", \"7u300/b30\", \"7u310\");\n            backports.assertLabeled();\n        }\n    }\n\n    @Test\n    void jdk8ub130(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8/b130\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"9\", \"7u111\", \"openjdk7u\", \"6u121\", \"8u5\");\n            backports.assertLabeled(\"8u5\");\n        }\n    }\n\n    @Test\n    void jdk6(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"11\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"6u201\", \"6u211\");\n            backports.assertLabeled(\"6u211\");\n        }\n    }\n\n    @Test\n    void jdk11_0_3_bpr(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"12\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"11.0.4-oracle\", \"11.0.4\", \"11.0.3-oracle/b31\", \"11.0.2/b31\");\n            backports.assertLabeled(\"11.0.3-oracle\");\n        }\n    }\n\n    @Test\n    void jdk11_0_3_bpr2(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"11.0.2.0.1-oracle\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"11.0.4-oracle\", \"11.0.3-oracle/b31\");\n            backports.assertLabeled(\"11.0.3-oracle\");\n        }\n    }\n\n    @Test\n    void jdk8u_foo(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var backports = new BackportManager(credentials, \"8u341\");\n            backports.assertLabeled();\n\n            backports.addBackports(\"8u333-foo\", \"8u345-foo\", \"8u351\");\n            backports.assertLabeled(\"8u345-foo\", \"8u351\");\n        }\n    }\n\n    @Test\n    void findFixedIssueWithPattern(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo)) {\n            var issueProject = credentials.getIssueProject();\n            var issue = credentials.createIssue(issueProject, \"Issue\");\n            issue.setProperty(\"issuetype\", JSON.of(\"Bug\"));\n            var backport = credentials.createIssue(issueProject, \"Backport\");\n            backport.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            backport.setState(Issue.State.RESOLVED);\n            issue.addLink(Link.create(backport, \"backported by\").build());\n            var backport2 = credentials.createIssue(issueProject, \"Backport Foo\");\n            backport2.setProperty(\"issuetype\", JSON.of(\"Backport\"));\n            issue.addLink(Link.create(backport2, \"backported by\").build());\n\n            issue.setProperty(\"fixVersions\", JSON.array().add(\"17\"));\n            backport.setProperty(\"fixVersions\", JSON.array().add(\"16.0.2\"));\n            backport2.setProperty(\"fixVersions\", JSON.array().add(\"16u-cpu\"));\n\n            assertEquals(backport.id(), Backports.findFixedIssue(issue, Pattern.compile(\"16.*\")).orElseThrow().id());\n            assertTrue(Backports.findFixedIssue(issue, Pattern.compile(\".*cpu\")).isEmpty());\n            assertTrue(Backports.findFixedIssue(issue, Pattern.compile(\"17\")).isEmpty());\n\n            issue.setState(Issue.State.RESOLVED);\n            // Need to reload the issue from the store for this to be picked up.\n            issue = (TestIssueTrackerIssue) issueProject.issue(issue.id()).orElseThrow();\n            assertEquals(issue.id(), Backports.findFixedIssue(issue, Pattern.compile(\"17\")).orElseThrow().id());\n\n            backport2.setState(Issue.State.RESOLVED);\n            assertEquals(backport2.id(), Backports.findFixedIssue(issue, Pattern.compile(\".*cpu\")).orElseThrow().id());\n        }\n    }\n}\n"
  },
  {
    "path": "jbs/src/test/java/org/openjdk/skara/jbs/BuildCompareTests.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jbs;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\npublic class BuildCompareTests {\n    @Test\n    void simple() {\n        assertTrue(BuildCompare.shouldReplace(\"main\", \"team\"));\n        assertFalse(BuildCompare.shouldReplace(\"team\", \"main\"));\n\n        assertTrue(BuildCompare.shouldReplace(\"b03\", \"team\"));\n        assertTrue(BuildCompare.shouldReplace(\"b03\", \"main\"));\n        assertTrue(BuildCompare.shouldReplace(\"b03\", \"b04\"));\n\n        assertFalse(BuildCompare.shouldReplace(\"team\", \"b03\"));\n        assertFalse(BuildCompare.shouldReplace(\"main\", \"b03\"));\n        assertFalse(BuildCompare.shouldReplace(\"b04\", \"b03\"));\n\n        assertTrue(BuildCompare.shouldReplace(\"team\", null));\n        assertTrue(BuildCompare.shouldReplace(\"main\", null));\n        assertTrue(BuildCompare.shouldReplace(\"b05\", null));\n\n        assertFalse(BuildCompare.shouldReplace(\"team\", \"team\"));\n        assertFalse(BuildCompare.shouldReplace(\"main\", \"main\"));\n        assertFalse(BuildCompare.shouldReplace(\"b12\", \"b12\"));\n    }\n}\n"
  },
  {
    "path": "jbs/src/test/java/org/openjdk/skara/jbs/JdkVersionTests.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jbs;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\npublic class JdkVersionTests {\n    private JdkVersion from(String raw) {\n        return JdkVersion.parse(raw).orElseThrow();\n    }\n\n    @Test\n    void jep223() {\n        assertEquals(List.of(\"8\"), from(\"8\").components());\n        assertEquals(List.of(\"9\", \"0\", \"4\"), from(\"9.0.4\").components());\n        assertEquals(List.of(\"10\", \"0\", \"2\"), from(\"10.0.2\").components());\n        assertEquals(List.of(\"11\"), from(\"11\").components());\n        assertEquals(List.of(\"11\", \"0\", \"3\"), from(\"11.0.3\").components());\n        assertEquals(List.of(\"12\", \"0\", \"2\"), from(\"12.0.2\").components());\n    }\n\n    @Test\n    void jep322() {\n        assertEquals(List.of(\"11\", \"0\", \"2\", \"0\", \"1\"), from(\"11.0.2.0.1-oracle\").components());\n        assertEquals(\"oracle\", from(\"11.0.2.0.1-oracle\").opt().orElseThrow());\n        assertEquals(List.of(\"11\", \"0\", \"3\"), from(\"11.0.3-oracle\").components());\n        assertEquals(\"oracle\", from(\"11.0.3-oracle\").opt().orElseThrow());\n        var fooVersion = from(\"11.0.12-foo-bar\");\n        assertEquals(List.of(\"11\", \"0\", \"12\"), fooVersion.components());\n        assertEquals(\"foo-bar\", fooVersion.opt().orElseThrow());\n    }\n\n    @Test\n    void legacy() {\n        assertEquals(List.of(\"5.0\", \"45\"), from(\"5.0u45\").components());\n        assertEquals(List.of(\"6\", \"201\"), from(\"6u201\").components());\n        assertEquals(List.of(\"7\", \"40\"), from(\"7u40\").components());\n        assertEquals(List.of(\"8\", \"211\"), from(\"8u211\").components());\n        assertEquals(List.of(\"emb-8\", \"171\"), from(\"emb-8u171\").components());\n        assertEquals(List.of(\"hs22\", \"4\"), from(\"hs22.4\").components());\n        assertEquals(List.of(\"hs23\"), from(\"hs23\").components());\n        assertEquals(List.of(\"openjdk7\"), from(\"openjdk7\").components());\n        assertEquals(List.of(\"openjdk8\"), from(\"openjdk8\").components());\n        assertEquals(List.of(\"openjdk8\", \"211\"), from(\"openjdk8u211\").components());\n        assertEquals(List.of(\"shenandoah8\", \"211\"), from(\"shenandoah8u211\").components());\n        assertEquals(List.of(\"foobar8\", \"211\"), from(\"foobar8u211\").components());\n    }\n\n    @Test\n    void openjfx11() {\n        assertEquals(List.of(\"openjfx11\", \"0\", \"12\"), from(\"openjfx11.0.12\").components());\n        assertEquals(List.of(\"openjfx17\", \"3\", \"4\", \"5\", \"6\"), from(\"openjfx17.3.4.5.6\").components());\n    }\n\n\n    @Test\n    void futureUpdates() {\n        assertEquals(List.of(\"16u\"), from(\"16u\").components());\n        var jdk16uCpu = from(\"16u-cpu\");\n        assertEquals(List.of(\"16u\"), jdk16uCpu.components());\n        assertEquals(\"cpu\", jdk16uCpu.opt().orElseThrow());\n        assertEquals(List.of(\"openjdk7u\"), from(\"openjdk7u\").components());\n        var jfx20uCpu = from(\"jfx20u-cpu\");\n        assertEquals(List.of(\"jfx20u\"), jfx20uCpu.components());\n        assertEquals(\"cpu\", jfx20uCpu.opt().orElseThrow());\n    }\n\n    @Test\n    void jdkCpu() {\n        var jdkCpu = from(\"jdk-cpu\");\n        assertEquals(List.of(\"jdk\"), jdkCpu.components());\n        assertEquals(\"cpu\", jdkCpu.opt().orElseThrow());\n    }\n\n    @Test\n    void jfxCpu() {\n        var jfxCpu = from(\"jfx-cpu\");\n        assertEquals(List.of(\"jfx\"), jfxCpu.components());\n        assertEquals(\"cpu\", jfxCpu.opt().orElseThrow());\n    }\n\n    @Test\n    void order() {\n        assertEquals(0, from(\"5.0u45\").compareTo(from(\"5.0u45\")));\n        assertEquals(0, from(\"11.0.3\").compareTo(from(\"11.0.3\")));\n        assertEquals(0, from(\"11.0.2.0.1-oracle\").compareTo(from(\"11.0.2.0.1-oracle\")));\n\n        assertEquals(1, from(\"6u201\").compareTo(from(\"5.0u45\")));\n        assertEquals(-1, from(\"5.0u45\").compareTo(from(\"6u201\")));\n\n        assertEquals(-1, from(\"11.0.2.0.1\").compareTo(from(\"11.0.2.0.1-oracle\")));\n        assertEquals(1, from(\"11.0.2.0.1-oracle\").compareTo(from(\"11.0.2.0.1\")));\n\n        assertEquals(-1, from(\"9.0.4\").compareTo(from(\"10.0.2\")));\n        assertEquals(-1, from(\"11\").compareTo(from(\"11.0.3\")));\n        assertEquals(-1, from(\"emb-8u171\").compareTo(from(\"emb-8u175\")));\n        assertEquals(-1, from(\"emb-8u71\").compareTo(from(\"emb-8u170\")));\n        assertEquals(-1, from(\"openjdk7\").compareTo(from(\"openjdk7u42\")));\n        assertEquals(-1, from(\"hs22.4\").compareTo(from(\"hs23\")));\n        assertEquals(-1, from(\"openjfx11.0.12\").compareTo(from(\"openjfx17.3.4.5.6\")));\n    }\n\n    @Test\n    void cpuOrder() {\n        assertEquals(-1, from(\"16\").compareTo(from(\"16u-cpu\")));\n        assertEquals(-1, from(\"16.0.2\").compareTo(from(\"16u-cpu\")));\n        assertEquals(1, from(\"17\").compareTo(from(\"16u-cpu\")));\n    }\n\n    @Test\n    void jdkCpuOrder() {\n        assertTrue(from(\"16\").compareTo(from(\"jdk-cpu\")) < 0);\n        assertTrue(from(\"16.0.2\").compareTo(from(\"jdk-cpu\")) < 0);\n        assertTrue(from(\"17\").compareTo(from(\"jdk-cpu\")) < 0);\n    }\n\n    @Test\n    void nonConforming() {\n        assertEquals(Optional.empty(), JdkVersion.parse(\"bla\"));\n        assertEquals(Optional.empty(), JdkVersion.parse(\"\"));\n    }\n\n    @Test\n    void legacyOpt() {\n        assertEquals(List.of(\"8\", \"333\"), from(\"8u333-foo\").components());\n        assertEquals(\"foo\", from(\"8u333-foo\").opt().orElseThrow());\n    }\n\n    @Test\n    void teamRepo() {\n        assertEquals(List.of(\"repo\", \"foo\"), from(\"repo-foo\").components());\n        assertTrue(from(\"20\").compareTo(from(\"repo-foo\")) < 0);\n    }\n\n    @Test\n    void teamBranch() {\n        assertEquals(List.of(\"branch\", \"foo\"), from(\"branch-foo\").components());\n        assertTrue(from(\"20\").compareTo(from(\"branch-foo\")) < 0);\n    }\n}\n"
  },
  {
    "path": "jcheck/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.jcheck'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.junit.jupiter.params'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.jcheck' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':vcs')\n    implementation project(':census')\n    implementation project(':ini')\n    testImplementation project(':test')\n}\n\npublishing {\n    publications {\n        jcheck(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.jcheck {\n    requires transitive org.openjdk.skara.vcs;\n    requires transitive org.openjdk.skara.census;\n    requires org.openjdk.skara.ini;\n    requires java.logging;\n\n    exports org.openjdk.skara.jcheck;\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/AuthorCheck.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nimport java.util.Iterator;\nimport java.util.logging.Logger;\n\npublic class AuthorCheck extends CommitCheck {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.author\");\n\n    @Override\n    Iterator<Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census) {\n        var metadata = CommitIssue.metadata(commit, message, conf, this);\n\n        var author = commit.author();\n        if (author.name() == null || author.name().isEmpty()) {\n            log.finer(\"issue: author.name is null or empty\");\n            return iterator(new AuthorNameIssue(metadata));\n        }\n        if (author.email() == null || author.email().isEmpty()) {\n            log.finer(\"issue: author.email is null or empty\");\n            return iterator(new AuthorEmailIssue(metadata));\n        }\n\n        return iterator();\n    }\n\n    @Override\n    public String name() {\n        return \"author\";\n    }\n\n    @Override\n    public String description() {\n        return \"Change must contain a proper author\";\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/AuthorEmailIssue.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\npublic class AuthorEmailIssue extends CommitIssue {\n    AuthorEmailIssue(CommitIssue.Metadata metadata) {\n        super(metadata);\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/AuthorNameIssue.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\npublic class AuthorNameIssue extends CommitIssue {\n    AuthorNameIssue(CommitIssue.Metadata metadata) {\n        super(metadata);\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/BinaryCheck.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nimport java.util.Iterator;\nimport java.util.ArrayList;\nimport java.util.logging.Logger;\n\npublic class BinaryCheck extends CommitCheck {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.binary\");\n\n    @Override\n    Iterator<Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census) {\n        var metadata = CommitIssue.metadata(commit, message, conf, this);\n\n        var issues = new ArrayList<Issue>();\n        for (var diff : commit.parentDiffs()) {\n            for (var patch : diff.patches()) {\n                if (patch.isBinary() &&\n                    (patch.status().isAdded() || patch.status().isCopied())) {\n                    issues.add(new BinaryIssue(patch.target().path().get(), metadata));\n                }\n            }\n        }\n\n        return issues.iterator();\n    }\n\n    @Override\n    public String name() {\n        return \"binary\";\n    }\n\n    @Override\n    public String description() {\n        return \"Files should not be binary\";\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/BinaryIssue.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport java.nio.file.Path;\n\npublic class BinaryIssue extends CommitIssue {\n    private final Path path;\n\n    BinaryIssue(Path path, CommitIssue.Metadata metadata) {\n        super(metadata);\n        this.path = path;\n    }\n\n    public Path path() {\n        return path;\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/BranchIssue.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.Branch;\n\npublic class BranchIssue extends Issue {\n    private final Branch branch;\n\n    BranchIssue(Branch branch, Check check) {\n        super(Severity.ERROR, check);\n        this.branch = branch;\n    }\n\n    public Branch branch() {\n        return this.branch;\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/BranchesCheck.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.Branch;\nimport org.openjdk.skara.vcs.ReadOnlyRepository;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.util.regex.Pattern;\n\nimport java.util.Iterator;\nimport java.util.logging.Logger;\n\npublic class BranchesCheck extends RepositoryCheck {\n    private final Pattern allowed;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.branches\");\n\n    BranchesCheck(Pattern allowed) {\n        this.allowed = allowed;\n    }\n\n    private boolean isAllowed(Branch b, ReadOnlyRepository repo) {\n        try {\n            return b.equals(repo.defaultBranch()) || allowed.matcher(b.name()).matches();\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    Iterator<Issue> check(ReadOnlyRepository repo) {\n        log.finer(\"Allowed branches: \" + allowed.toString());\n        try {\n            return repo.branches()\n                       .stream()\n                       .filter(b -> !isAllowed(b, repo))\n                       .map(b -> (Issue) new BranchIssue(b, this))\n                       .iterator();\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public String name() {\n        return \"branches\";\n    }\n\n    @Override\n    public String description() {\n        return \"Branch names must use correct syntax\";\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/CensusConfiguration.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.ini.Section;\nimport java.net.URI;\n\npublic class CensusConfiguration {\n    private static final CensusConfiguration DEFAULT =\n        new CensusConfiguration(0, \"localhost\", URI.create(\"https://openjdk.org/census.xml\"));\n\n    private final int version;\n    private final String domain;\n    private final URI url;\n\n    CensusConfiguration(int version, String domain, URI url) {\n        this.version = version;\n        this.domain = domain;\n        this.url = url;\n    }\n\n    public int version() {\n        return version;\n    }\n\n    public String domain() {\n        return domain;\n    }\n\n    public URI url() {\n        return url;\n    }\n\n    static String name() {\n        return \"census\";\n    }\n\n    static CensusConfiguration parse(Section s) {\n        if (s == null) {\n            return DEFAULT;\n        }\n\n        var version = s.get(\"version\", DEFAULT.version());\n        var domain = s.get(\"domain\", DEFAULT.domain());\n        var url = s.get(\"url\", DEFAULT.url().toString());\n        return new CensusConfiguration(version, domain, URI.create(url));\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/Check.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\npublic interface Check {\n    String name();\n    String description();\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/ChecksConfiguration.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.ini.Section;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class ChecksConfiguration {\n    private static ChecksConfiguration DEFAULT =\n        new ChecksConfiguration(List.of(),\n                                List.of(),\n                                WhitespaceConfiguration.DEFAULT,\n                                ReviewersConfiguration.DEFAULT,\n                                MergeConfiguration.DEFAULT,\n                                CommitterConfiguration.DEFAULT,\n                                IssuesConfiguration.DEFAULT,\n                                ProblemListsConfiguration.DEFAULT,\n                                null);\n\n    private final List<String> error;\n    private final List<String> warning;\n    private final WhitespaceConfiguration whitespace;\n    private final ReviewersConfiguration reviewers;\n    private final MergeConfiguration merge;\n    private final CommitterConfiguration committer;\n    private final IssuesConfiguration issues;\n    private final ProblemListsConfiguration problemlists;\n    private final CopyrightFormatConfiguration copyright;\n\n    ChecksConfiguration(List<String> error,\n                        List<String> warning,\n                        WhitespaceConfiguration whitespace,\n                        ReviewersConfiguration reviewers,\n                        MergeConfiguration merge,\n                        CommitterConfiguration committer,\n                        IssuesConfiguration issues,\n                        ProblemListsConfiguration problemlists,\n                        CopyrightFormatConfiguration copyright) {\n        this.error = error;\n        this.warning = warning;\n        this.whitespace = whitespace;\n        this.reviewers = reviewers;\n        this.merge = merge;\n        this.committer = committer;\n        this.issues = issues;\n        this.problemlists = problemlists;\n        this.copyright = copyright;\n    }\n\n    public List<String> error() {\n        return error;\n    }\n\n    public List<String> warning() {\n        return warning;\n    }\n\n    public Severity severity(String name) {\n        if (error.contains(name)) {\n            return Severity.ERROR;\n        }\n\n        if (warning.contains(name)) {\n            return Severity.WARNING;\n        }\n\n        return null;\n    }\n\n    public List<CommitCheck> enabled(List<CommitCheck> available) {\n        return available.stream()\n                        .filter(c -> error.contains(c.name()) || warning.contains(c.name()))\n                        .collect(Collectors.toList());\n    }\n\n    public WhitespaceConfiguration whitespace() {\n        return whitespace;\n    }\n\n    public ReviewersConfiguration reviewers() {\n        return reviewers;\n    }\n\n    public MergeConfiguration merge() {\n        return merge;\n    }\n\n    public CommitterConfiguration committer() {\n        return committer;\n    }\n\n    public IssuesConfiguration issues() {\n        return issues;\n    }\n\n    public ProblemListsConfiguration problemlists() {\n        return problemlists;\n    }\n\n    public CopyrightFormatConfiguration copyright(){\n        return copyright;\n    }\n\n    static String name() {\n        return \"checks\";\n    }\n\n    static ChecksConfiguration parse(Section s) {\n        if (s == null) {\n            return DEFAULT;\n        }\n\n        var error = s.get(\"error\", DEFAULT.error());\n        var warning = s.get(\"warning\", DEFAULT.warning());\n\n        var whitespace = WhitespaceConfiguration.parse(s.subsection(WhitespaceConfiguration.name()));\n        var reviewers = ReviewersConfiguration.parse(s.subsection(ReviewersConfiguration.name()));\n        var merge = MergeConfiguration.parse(s.subsection(MergeConfiguration.name()));\n        var committer = CommitterConfiguration.parse(s.subsection(CommitterConfiguration.name()));\n        var issues = IssuesConfiguration.parse(s.subsection(IssuesConfiguration.name()));\n        var problemlists = ProblemListsConfiguration.parse(s.subsection(ProblemListsConfiguration.name()));\n        var copyright = CopyrightFormatConfiguration.parse(s.subsection(CopyrightFormatConfiguration.name()));\n        return new ChecksConfiguration(error, warning, whitespace, reviewers, merge, committer, issues, problemlists, copyright);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/CommitCheck.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\nimport org.openjdk.skara.vcs.Commit;\n\nimport java.util.Arrays;\nimport java.util.Iterator;\n\nabstract class CommitCheck implements Check {\n    abstract Iterator<Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census);\n\n    protected Iterator<Issue> iterator(Issue... issues) {\n        return Arrays.asList(issues).iterator();\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/CommitIssue.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\npublic abstract class CommitIssue extends Issue {\n    private final Commit commit;\n    private final CommitMessage message;\n\n    static final class Metadata {\n        private final Severity severity;\n        private final Check check;\n        private final Commit commit;\n        private final CommitMessage message;\n\n        private Metadata(Severity severity,\n                         Check check,\n                         Commit commit,\n                         CommitMessage message) {\n            this.severity = severity;\n            this.check = check;\n            this.commit = commit;\n            this.message = message;\n        }\n    }\n\n    CommitIssue(CommitIssue.Metadata metadata) {\n        super(metadata.severity, metadata.check);\n        this.commit = metadata.commit;\n        this.message = metadata.message;\n    }\n\n    public Commit commit() {\n        return commit;\n    }\n\n    public CommitMessage message() {\n        return message;\n    }\n\n    static Metadata metadata(Commit commit, CommitMessage message, JCheckConfiguration conf, CommitCheck check) {\n        return new Metadata(conf.checks().severity(check.name()), check, commit, message);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/CommitterCheck.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.census.Project;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nimport java.util.ArrayList;\nimport java.util.Iterator;\nimport java.util.logging.Logger;\n\npublic class CommitterCheck extends CommitCheck {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.committer\");\n\n    private boolean hasRole(Census census, Project project, String role, String username, int version) {\n        switch (role) {\n            case \"lead\":\n                return project.isLead(username, version);\n            case \"reviewer\":\n                return project.isReviewer(username, version);\n            case \"committer\":\n                return project.isCommitter(username, version);\n            case \"author\":\n                return project.isAuthor(username, version);\n            case \"contributor\":\n                return census.isContributor(username);\n            default:\n                throw new IllegalStateException(\"Unsupported role: \" + role);\n        }\n    }\n\n\n    @Override\n    Iterator<Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census) {\n        var issues = new ArrayList<Issue>();\n        var project = census.project(conf.general().project());\n        var version = conf.census().version();\n        var domain = conf.census().domain();\n        var role = conf.checks().committer().role();\n        var metadata = CommitIssue.metadata(commit, message, conf, this);\n\n        var committer = commit.committer();\n        if (committer.name() == null || committer.name().isEmpty()) {\n            log.finer(\"issue: committer.name is null or empty\");\n            issues.add(new CommitterNameIssue(metadata));\n        }\n        if (committer.email() == null || !committer.email().endsWith(\"@\" + domain)) {\n            log.finer(\"issue: committer.email is null or does not end with @\" + domain);\n            issues.add(new CommitterEmailIssue(domain, metadata));\n        }\n\n        if (committer.name() != null || committer.email() != null) {\n            var username = committer.email() == null ?\n                committer.name() : committer.email().split(\"@\")[0];\n            var allowedToMerge = conf.checks().committer().allowedToMerge();\n            if (!commit.isMerge() || !allowedToMerge.contains(username)) {\n                if (!hasRole(census, project, role, username, version)) {\n                    log.finer(\"issue: committer does not have role \" + role);\n                    issues.add(new CommitterIssue(project, metadata));\n                }\n            }\n        }\n\n        return issues.iterator();\n    }\n\n    @Override\n    public String name() {\n        return \"committer\";\n    }\n\n    @Override\n    public String description() {\n        return \"Change must contain a proper committer\";\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/CommitterConfiguration.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.ini.Section;\n\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\npublic class CommitterConfiguration {\n    static final CommitterConfiguration DEFAULT = new CommitterConfiguration(\"committer\", Set.of());\n\n    private final String role;\n    private final Set<String> allowedToMerge;\n\n    CommitterConfiguration(String role, Set<String> allowedToMerge) {\n        this.role = role;\n        this.allowedToMerge = allowedToMerge;\n    }\n\n    public String role() {\n        return role;\n    }\n\n    public Set<String> allowedToMerge() {\n        return allowedToMerge;\n    }\n\n    static String name() {\n        return \"committer\";\n    }\n\n    static CommitterConfiguration parse(Section s) {\n        if (s == null) {\n            return DEFAULT;\n        }\n\n        var role = s.get(\"role\", DEFAULT.role());\n        var usernames = s.get(\"allowed-to-merge\", \"\");\n        var allowedToMerge = new HashSet<String>();\n        for (var username : usernames.split(\",\")) {\n            allowedToMerge.add(username.trim());\n        }\n        return new CommitterConfiguration(role, allowedToMerge);\n    }\n}\n\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/CommitterEmailIssue.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\npublic class CommitterEmailIssue extends CommitIssue {\n    private final String expectedDomain;\n\n    CommitterEmailIssue(String expectedDomain, CommitIssue.Metadata metadata) {\n        super(metadata);\n        this.expectedDomain = expectedDomain;\n    }\n\n    public String expectedDomain() {\n        return expectedDomain;\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/CommitterIssue.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Project;\n\npublic class CommitterIssue extends CommitIssue {\n    private final Project project;\n\n    public CommitterIssue(Project project, CommitIssue.Metadata metadata) {\n        super(metadata);\n        this.project = project;\n    }\n\n    public Project project() {\n        return project;\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/CommitterNameIssue.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\npublic class CommitterNameIssue extends CommitIssue {\n    CommitterNameIssue(CommitIssue.Metadata metadata) {\n        super(metadata);\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/CopyrightFormatCheck.java",
    "content": "/*\n * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.ReadOnlyRepository;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.regex.Pattern;\n\npublic class CopyrightFormatCheck extends CommitCheck {\n\n    private final ReadOnlyRepository repo;\n\n    CopyrightFormatCheck(ReadOnlyRepository repo) {\n        this.repo = repo;\n    }\n\n    @Override\n    Iterator<Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census) {\n        var metadata = CommitIssue.metadata(commit, message, conf, this);\n        var copyrightConf = conf.checks().copyright();\n        if (copyrightConf == null) {\n            return iterator();\n        }\n        var filesPattern = Pattern.compile(copyrightConf.files());\n        var copyrightConfigs = copyrightConf.copyrightConfigs();\n        var filesWithCopyrightFormatIssue = new HashMap<String, List<String>>();\n        var filesWithCopyrightMissingIssue = new HashMap<String, List<String>>();\n\n        for (var diff : commit.parentDiffs()) {\n            for (var patch : diff.patches()) {\n                if (patch.target().path().isEmpty() || patch.isBinary()) {\n                    continue;\n                }\n                var path = patch.target().path().get();\n                // Check if we need to check copyright in this type of file\n                if (filesPattern.matcher(path.toString()).matches()) {\n                    try {\n                        var lines = repo.lines(path, commit.hash()).orElse(List.of());\n                        // Iterate over every kind of configured copyright\n                        for (var singleConf : copyrightConfigs) {\n                            var copyrightFound = false;\n                            for (String line : lines) {\n                                if (singleConf.locator().matcher(line).matches()) {\n                                    copyrightFound = true;\n                                    if (!singleConf.validator().matcher(line).matches()) {\n                                        filesWithCopyrightFormatIssue\n                                                .computeIfAbsent(singleConf.name(), k -> new ArrayList<>())\n                                                .add(path.toString());\n                                    }\n                                    break;\n                                }\n                            }\n                            if (singleConf.required() && !copyrightFound) {\n                                filesWithCopyrightMissingIssue\n                                        .computeIfAbsent(singleConf.name(), k -> new ArrayList<>())\n                                        .add(path.toString());\n                            }\n                        }\n                    } catch (IOException e) {\n                        throw new UncheckedIOException(e);\n                    }\n                }\n            }\n        }\n\n        if (!filesWithCopyrightFormatIssue.isEmpty() || !filesWithCopyrightMissingIssue.isEmpty()) {\n            return iterator(new CopyrightFormatIssue(metadata, filesWithCopyrightFormatIssue, filesWithCopyrightMissingIssue));\n        }\n\n        return iterator();\n    }\n\n    @Override\n    public String name() {\n        return \"copyright\";\n    }\n\n    @Override\n    public String description() {\n        return \"Copyright should be properly formatted\";\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/CopyrightFormatConfiguration.java",
    "content": "/*\n * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.ini.Section;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.regex.Pattern;\n\npublic class CopyrightFormatConfiguration {\n\n    /**\n     * Configuration for a copyright check\n     *\n     * @param name      The Name for the copyright check.\n     * @param locator   A Regex used to locate the copyright line.\n     * @param validator A Regex used to validate the copyright line.\n     * @param required  Indicates whether a copyright is required for each file; if true, the check will fail if the copyright is missing.\n     */\n    public record CopyrightConfiguration(String name, Pattern locator, Pattern validator, boolean required) {\n    }\n\n    private final String files;\n    private final List<CopyrightConfiguration> copyrightConfigs;\n\n    CopyrightFormatConfiguration(String files, List<CopyrightConfiguration> copyrightConfigs) {\n        this.files = files;\n        this.copyrightConfigs = copyrightConfigs;\n    }\n\n    public String files() {\n        return files;\n    }\n\n    public List<CopyrightConfiguration> copyrightConfigs() {\n        return copyrightConfigs;\n    }\n\n    static String name() {\n        return \"copyright\";\n    }\n\n    static CopyrightFormatConfiguration parse(Section s) {\n        if (s == null) {\n            return null;\n        }\n\n        var files = s.get(\"files\").asString();\n        var configurations = new ArrayList<CopyrightConfiguration>();\n        for (var entry : s.entries()) {\n            var key = entry.key();\n            var value = entry.value();\n            if (key.contains(\"locator\")) {\n                var name = key.split(\"_\")[0];\n                var locator = Pattern.compile(value.asString());\n                var validator = Pattern.compile(s.get(name + \"_validator\", \"\"));\n                var required = s.get(name + \"_required\", false);\n                configurations.add(new CopyrightConfiguration(name, locator, validator, required));\n            }\n        }\n        return new CopyrightFormatConfiguration(files, configurations);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/CopyrightFormatIssue.java",
    "content": "/*\n * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport java.util.HashMap;\nimport java.util.List;\n\npublic class CopyrightFormatIssue extends CommitIssue {\n\n    HashMap<String, List<String>> filesWithCopyrightFormatIssue;\n    HashMap<String, List<String>> filesWithCopyrightMissingIssue;\n\n    CopyrightFormatIssue(CommitIssue.Metadata metadata, HashMap<String, List<String>> filesWithCopyrightFormatIssue, HashMap<String, List<String>> filesWithCopyrightMissingIssue) {\n        super(metadata);\n        this.filesWithCopyrightFormatIssue = filesWithCopyrightFormatIssue;\n        this.filesWithCopyrightMissingIssue = filesWithCopyrightMissingIssue;\n    }\n\n    @Override\n    public void accept(IssueVisitor visitor) {\n        visitor.visit(this);\n    }\n\n    public HashMap<String, List<String>> filesWithCopyrightFormatIssue() {\n        return filesWithCopyrightFormatIssue;\n    }\n\n    public HashMap<String, List<String>> filesWithCopyrightMissingIssue() {\n        return filesWithCopyrightMissingIssue;\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/DuplicateIssuesCheck.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.ReadOnlyRepository;\nimport org.openjdk.skara.vcs.openjdk.Issue;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.util.*;\nimport java.util.logging.Logger;\n\npublic class DuplicateIssuesCheck extends CommitCheck {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.duplicate-issues\");\n    private final ReadOnlyRepository repo;\n    private Map<String, List<Hash>> issuesToHashes = null;\n\n    DuplicateIssuesCheck(ReadOnlyRepository repo) {\n        this.repo = repo;\n    }\n\n    private void populateIssuesToHashesMap() throws IOException {\n        issuesToHashes = new HashMap<>();\n\n        for (var metadata : repo.commitMetadata()) {\n            for (var line : metadata.message()) {\n                var issue = Issue.fromString(line);\n                if (issue.isPresent()) {\n                    var id = issue.get().id();\n                    if (!issuesToHashes.containsKey(id)) {\n                        issuesToHashes.put(id, new ArrayList<>());\n                    }\n                    issuesToHashes.get(id).add(metadata.hash());\n                }\n            }\n        }\n    }\n\n    @Override\n    Iterator<org.openjdk.skara.jcheck.Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census) {\n        try {\n            if (issuesToHashes == null) {\n                populateIssuesToHashesMap();\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n\n        var metadata = CommitIssue.metadata(commit, message, conf, this);\n        var issues = new ArrayList<org.openjdk.skara.jcheck.Issue>();\n        for (var issue : message.issues()) {\n            var hashes = issuesToHashes.get(issue.id());\n            if (hashes != null && hashes.size() > 1) {\n                // Check if any of the found hashes is an ancestor of the current commit\n                var ancestorHashes = new ArrayList<Hash>();\n                for (var hash : hashes) {\n                    if (hash.equals(commit.hash())) {\n                        ancestorHashes.add(hash);\n                    } else {\n                        try {\n                            if (repo.isAncestor(hash, commit.hash())) {\n                                ancestorHashes.add(hash);\n                            }\n                        } catch (IOException e) {\n                            throw new UncheckedIOException(e);\n                        }\n                    }\n                }\n                if (ancestorHashes.size() > 1) {\n                    log.finer(\"issue: the JBS issue \" + issue.toString() + \" has been used in multiple commits\");\n                    var uniqueHashes = new ArrayList<>(new HashSet<>(hashes));\n                    issues.add(new DuplicateIssuesIssue(issue, uniqueHashes, metadata));\n                }\n            }\n        }\n        return issues.iterator();\n    }\n\n    @Override\n    public String name() {\n        return \"duplicate-issues\";\n    }\n\n    @Override\n    public String description() {\n        return \"Referenced JBS issue must only be used for a single change\";\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/DuplicateIssuesIssue.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.openjdk.Issue;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class DuplicateIssuesIssue extends CommitIssue {\n    private final Issue issue;\n    private final List<Hash> hashes;\n\n    DuplicateIssuesIssue(Issue issue, List<Hash> hashes, CommitIssue.Metadata metadata) {\n        super(metadata);\n        this.issue = issue;\n        this.hashes = hashes;\n    }\n\n    public Issue issue() {\n        return this.issue;\n    }\n\n    public List<Hash> hashes() {\n        return this.hashes;\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/ExecutableCheck.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nimport java.util.Iterator;\nimport java.util.ArrayList;\nimport java.util.logging.Logger;\n\npublic class ExecutableCheck extends CommitCheck {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.executable\");\n\n    @Override\n    Iterator<Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census) {\n        var metadata = CommitIssue.metadata(commit, message, conf, this);\n\n        var issues = new ArrayList<Issue>();\n        for (var diff : commit.parentDiffs()) {\n            for (var patch : diff.patches()) {\n                if (patch.target().type().isPresent()) {\n                    var type = patch.target().type().get();\n                    if (type.isExecutable()) {\n                        var path = patch.target().path().get();\n                        log.finer(\"issue: \" + path + \" is executable\");\n                        issues.add(new ExecutableIssue(path, metadata));\n                    }\n                }\n            }\n        }\n\n        return issues.iterator();\n    }\n\n    @Override\n    public String name() {\n        return \"executable\";\n    }\n\n    @Override\n    public String description() {\n        return \"Files should not be executable\";\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/ExecutableIssue.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport java.nio.file.Path;\n\npublic class ExecutableIssue extends CommitIssue {\n    private final Path path;\n\n    ExecutableIssue(Path path, CommitIssue.Metadata metadata) {\n        super(metadata);\n        this.path = path;\n    }\n\n    public Path path() {\n        return path;\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/GeneralConfiguration.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.ini.Section;\n\nimport java.util.Optional;\n\npublic class GeneralConfiguration {\n    private static final GeneralConfiguration DEFAULT = new GeneralConfiguration(null, null, null, null);\n\n    private final String project;\n    private final String repository;\n    private final String jbs;\n    private final String version;\n\n    private GeneralConfiguration(String project, String repository, String jbs, String version) {\n        this.project = project;\n        this.repository = repository;\n        this.jbs = jbs;\n        this.version = version;\n    }\n\n    public String project() {\n        return project;\n    }\n\n    public String repository() {\n        return repository;\n    }\n\n    public String jbs() {\n        return jbs;\n    }\n\n    public Optional<String> version() {\n        return Optional.ofNullable(version);\n    }\n\n    static String name() {\n        return \"general\";\n    }\n\n    static GeneralConfiguration parse(Section s) {\n        if (s == null) {\n            return DEFAULT;\n        }\n\n        var project = s.get(\"project\", DEFAULT.project());\n        var repository = s.get(\"repository\", DEFAULT.repository());\n        var jbs = s.get(\"jbs\", DEFAULT.jbs());\n        var version = s.get(\"version\", DEFAULT.version().orElse(null));\n        return new GeneralConfiguration(project, repository, jbs, version);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/HgTagCommitCheck.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nimport java.util.regex.Pattern;\nimport java.util.Iterator;\nimport java.util.logging.Logger;\n\npublic class HgTagCommitCheck extends CommitCheck {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.hg-tag\");\n    private final Utilities utils;\n\n    HgTagCommitCheck(Utilities utils) {\n        this.utils = utils;\n    }\n\n    @Override\n    Iterator<Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census) {\n        if (commit.isMerge() || !utils.addsHgTag(commit)) {\n            return iterator();\n        }\n        var metadata = CommitIssue.metadata(commit, message, conf, this);\n\n        if (commit.message().size() != 1) {\n            log.finer(\"issue: too many lines in commit message\");\n            return iterator(HgTagCommitIssue.tooManyLines(metadata));\n        }\n\n        var line = commit.message().get(0);\n        var pattern = Pattern.compile(\"Added tag (\" + conf.repository().tags() + \") for changeset ([a-zA-Z0-9]+)$\");\n        var matcher = pattern.matcher(line);\n        if (!matcher.matches()) {\n            log.finer(\"issue: commit message has wrong format\");\n            return iterator(HgTagCommitIssue.badFormat(metadata));\n        }\n\n        var diff = commit.parentDiffs().get(0);\n        var patches = diff.patches();\n        if (patches.size() != 1) {\n            log.finer(\"issue: too many patches\");\n            return iterator(HgTagCommitIssue.tooManyChanges(metadata));\n        }\n\n        var patch = patches.get(0);\n        if (!patch.target().path().isPresent() ||\n            !(patch.target().path().get().toString().endsWith(\".hgtags\") || patch.target().path().get().toString().endsWith(\".hgtags-top-repo\"))) {\n            log.severe(\"utils.addsHgTag returned true but commit does change .hgtags\");\n            throw new IllegalArgumentException(\"commit \" + commit.hash() + \" does not add a tag\");\n        }\n\n        if (patch.isBinary()) {\n            throw new IllegalArgumentException(\"commit \" + commit.hash() + \" contains binary patch to .hgtags\");\n        }\n\n        var hunks = patch.asTextualPatch().hunks();\n        if (hunks.size() != 1) {\n            log.finer(\"issue: too many hunks\");\n            return iterator(HgTagCommitIssue.tooManyChanges(metadata));\n        }\n\n        var hunk = hunks.get(0);\n        var lines = hunk.target().lines();\n        if (lines.size() != 1) {\n            log.finer(\"issue: too many lines\");\n            return iterator(HgTagCommitIssue.tooManyChanges(metadata));\n        }\n\n        var words = lines.get(0).split(\"\\\\s\");\n        var fileTag = words[1];\n        var messageTag = matcher.group(1);\n        if (!messageTag.equals(fileTag)) {\n            log.finer(\"issue: different tag in commit message and .hgtags\");\n            return iterator(HgTagCommitIssue.tagDiffers(metadata));\n        }\n\n        // Can't check that the hash of the tag matches here, there are too\n        // many tag commits from before the consolidation whose messages\n        // weren't updated to reflect the new hash for the tag.\n\n        return iterator();\n    }\n\n    @Override\n    public String name() {\n        return \"hg-tag\";\n    }\n\n    @Override\n    public String description() {\n        return \"Change must contain correct Mercurial tags\";\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/HgTagCommitIssue.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\npublic class HgTagCommitIssue extends CommitIssue {\n    public static enum Error {\n        TOO_MANY_LINES,\n        BAD_FORMAT,\n        TOO_MANY_CHANGES,\n        TAG_DIFFERS,\n    }\n\n    private final Error error;\n\n    HgTagCommitIssue(Error error, CommitIssue.Metadata metadata) {\n        super(metadata);\n        this.error = error;\n    }\n\n    public Error error() {\n        return error;\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n\n    static HgTagCommitIssue tooManyLines(CommitIssue.Metadata metadata) {\n        return new HgTagCommitIssue(Error.TOO_MANY_LINES, metadata);\n    }\n\n    static HgTagCommitIssue badFormat(CommitIssue.Metadata metadata) {\n        return new HgTagCommitIssue(Error.BAD_FORMAT, metadata);\n    }\n\n    static HgTagCommitIssue tooManyChanges(CommitIssue.Metadata metadata) {\n        return new HgTagCommitIssue(Error.TOO_MANY_CHANGES, metadata);\n    }\n\n    static HgTagCommitIssue tagDiffers(CommitIssue.Metadata metadata) {\n        return new HgTagCommitIssue(Error.TAG_DIFFERS, metadata);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/InvalidReviewersIssue.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Project;\n\nimport java.util.List;\n\npublic class InvalidReviewersIssue extends CommitIssue {\n    private final List<String> invalid;\n    private final Project project;\n\n    InvalidReviewersIssue(List<String> invalid, Project project, CommitIssue.Metadata metadata) {\n        super(metadata);\n        this.invalid = invalid;\n        this.project = project;\n    }\n\n    public List<String> invalid() {\n        return invalid;\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/Issue.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\npublic abstract class Issue {\n    private final Severity severity;\n    private final Check check;\n\n    Issue(Severity severity, Check check) {\n        this.severity = severity;\n        this.check = check;\n    }\n\n    public abstract void accept(IssueVisitor v);\n\n    public Severity severity() {\n        return severity;\n    }\n\n    public Check check() {\n        return check;\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/IssueVisitor.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\npublic interface IssueVisitor {\n    void visit(TagIssue issue);\n    void visit(BranchIssue issue);\n    void visit(DuplicateIssuesIssue issue);\n    void visit(SelfReviewIssue issue);\n    void visit(TooFewReviewersIssue issue);\n    void visit(InvalidReviewersIssue issue);\n    void visit(MergeMessageIssue issue);\n    void visit(HgTagCommitIssue issue);\n    void visit(CommitterIssue issue);\n    void visit(CommitterNameIssue issue);\n    void visit(CommitterEmailIssue issue);\n    void visit(AuthorNameIssue issue);\n    void visit(AuthorEmailIssue issue);\n    void visit(WhitespaceIssue issue);\n    void visit(MessageIssue issue);\n    void visit(MessageWhitespaceIssue issue);\n    void visit(IssuesIssue issue);\n    void visit(ExecutableIssue issue);\n    void visit(BinaryIssue issue);\n    void visit(SymlinkIssue issue);\n    void visit(ProblemListsIssue problemListIssue);\n    void visit(IssuesTitleIssue issuesTitleIssue);\n    void visit(CopyrightFormatIssue copyrightFormatIssue);\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/IssuesCheck.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nimport java.util.Iterator;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\n\npublic class IssuesCheck extends CommitCheck {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.issues\");\n    private final Utilities utils;\n\n    IssuesCheck(Utilities utils) {\n        this.utils = utils;\n    }\n\n    @Override\n    Iterator<Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census) {\n        if (commit.isMerge() || utils.addsHgTag(commit)) {\n            return iterator();\n        }\n\n        var metadata = CommitIssue.metadata(commit, message, conf, this);\n        if (conf.checks().issues().required() &&\n            (commit.message().isEmpty() || message.issues().isEmpty())) {\n            log.finer(\"issue: no reference to a JBS issue\");\n            return iterator(new IssuesIssue(metadata));\n        }\n\n        var pattern = Pattern.compile(conf.checks().issues().pattern());\n        for (var issue : message.issues()) {\n            if (!pattern.matcher(issue.toString()).matches()) {\n                return iterator(new IssuesIssue(metadata));\n            }\n        }\n\n        return iterator();\n    }\n\n    @Override\n    public String name() {\n        return \"issues\";\n    }\n\n    @Override\n    public String description() {\n        return \"Commit message must refer to an issue\";\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/IssuesConfiguration.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.ini.Section;\n\nimport java.util.List;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\npublic class IssuesConfiguration {\n    static final IssuesConfiguration DEFAULT =\n        new IssuesConfiguration(\"^(([A-Z][A-Z0-9]+-)?[0-9]+): (\\\\S.*)$\", true);\n\n    private final String pattern;\n    private final boolean required;\n\n    IssuesConfiguration(String pattern, boolean required) {\n        this.pattern = pattern;\n        this.required = required;\n    }\n\n    public String pattern() {\n        return pattern;\n    }\n\n    public boolean required() {\n        return required;\n    }\n\n    static String name() {\n        return \"issues\";\n    }\n\n    static IssuesConfiguration parse(Section s) {\n        if (s == null) {\n            return DEFAULT;\n        }\n\n        var pattern = s.get(\"pattern\", DEFAULT.pattern());\n        var required = s.get(\"required\", DEFAULT.required());\n        return new IssuesConfiguration(pattern, required);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/IssuesIssue.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\npublic class IssuesIssue extends CommitIssue {\n    IssuesIssue(CommitIssue.Metadata metadata) {\n        super(metadata);\n    }\n\n    @Override\n    public void accept(IssueVisitor visitor) {\n        visitor.visit(this);\n    }\n}\n\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/IssuesTitleCheck.java",
    "content": "/*\n * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nimport java.util.ArrayList;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\n\npublic class IssuesTitleCheck extends CommitCheck {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.issuesTitle\");\n    private final static List<String> VALID_WORD_WITH_TRAILING_PERIOD = List.of(\"et al.\", \"etc.\", \"...\");\n    private final static Pattern FIRST_WORD_ALL_LOWER_CASE_PATTERN = Pattern.compile(\"[a-z]+(?:\\\\h.*)?\");\n\n    @Override\n    Iterator<Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census) {\n\n        var metadata = CommitIssue.metadata(commit, message, conf, this);\n\n        // if issues check is not required, skip issuesTitleCheck\n        if (conf.checks().issues().required() &&\n                (commit.message().isEmpty() || message.issues().isEmpty())) {\n            return iterator();\n        }\n\n        var issuesWithTrailingPeriod = new ArrayList<String>();\n        var issuesWithLeadingLowerCaseLetter = new ArrayList<String>();\n\n        for (var issue : message.issues()) {\n            if (hasTrailingPeriod(issue.description())) {\n                issuesWithTrailingPeriod.add(\"`\" + issue + \"`\");\n            }\n            if (FIRST_WORD_ALL_LOWER_CASE_PATTERN.matcher(issue.description()).matches()) {\n                issuesWithLeadingLowerCaseLetter.add(\"`\" + issue + \"`\");\n            }\n        }\n        if (!issuesWithTrailingPeriod.isEmpty() || !issuesWithLeadingLowerCaseLetter.isEmpty()) {\n            return iterator(new IssuesTitleIssue(metadata, issuesWithTrailingPeriod, issuesWithLeadingLowerCaseLetter));\n        }\n        return iterator();\n    }\n\n    @Override\n    public String name() {\n        return \"issuestitle\";\n    }\n\n    @Override\n    public String description() {\n        return \"Issue's title should be properly formatted\";\n    }\n\n    private boolean hasTrailingPeriod(String description) {\n        if (!description.endsWith(\".\")) {\n            return false;\n        }\n        for (String phrase : VALID_WORD_WITH_TRAILING_PERIOD) {\n            if (description.endsWith(phrase)) {\n                return false;\n            }\n        }\n        return true;\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/IssuesTitleIssue.java",
    "content": "/*\n * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport java.util.List;\n\npublic class IssuesTitleIssue extends CommitIssue {\n    List<String> issuesWithTrailingPeriod;\n    List<String> issuesWithLeadingLowerCaseLetter;\n\n    IssuesTitleIssue(CommitIssue.Metadata metadata, List<String> issuesWithTrailingPeriod, List<String> issuesWithLeadingLowerCaseLetter) {\n        super(metadata);\n        this.issuesWithTrailingPeriod = issuesWithTrailingPeriod;\n        this.issuesWithLeadingLowerCaseLetter = issuesWithLeadingLowerCaseLetter;\n    }\n\n    @Override\n    public void accept(IssueVisitor visitor) {\n        visitor.visit(this);\n    }\n\n    public List<String> issuesWithTrailingPeriod() {\n        return issuesWithTrailingPeriod;\n    }\n\n    public List<String> issuesWithLeadingLowerCaseLetter() {\n        return issuesWithLeadingLowerCaseLetter;\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/JCheck.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.jcheck.iterators.*;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParser;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport java.net.URI;\nimport java.io.*;\nimport java.nio.file.Paths;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.*;\nimport java.util.logging.Logger;\n\npublic class JCheck {\n    private final ReadOnlyRepository repository;\n    private final CommitMessageParser parser;\n    private final String revisionRange;\n    private final List<CommitCheck> commitChecks;\n    private final static List<CommitCheck> commitChecksForStagedOrWorkingTree = List.of(\n            new AuthorCheck(),\n            new CommitterCheck(),\n            new WhitespaceCheck(),\n            new ExecutableCheck(),\n            new SymlinkCheck(),\n            new BinaryCheck()\n    );\n    private final List<RepositoryCheck> repositoryChecks;\n    private final List<String> additionalConfiguration;\n    private final JCheckConfiguration overridingConfiguration;\n    private final Census overridingCensus;\n    private final Map<URI, Census> censuses = new HashMap<>();\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck\");\n\n    public final static String WORKING_TREE_REV = \"SKARA_GIT_WORKING_TREE_AS_REV\";\n\n    public final static String STAGED_REV = \"SKARA_GIT_STAGED_AS_REV\";\n\n    JCheck(ReadOnlyRepository repository,\n           CommitMessageParser parser,\n           String revisionRange,\n           Pattern allowedBranches,\n           Pattern allowedTags,\n           List<String> additionalConfiguration,\n           JCheckConfiguration overridingConfiguration,\n           Census overridingCensus) throws IOException {\n        this.repository = repository;\n        this.parser = parser;\n        this.revisionRange = revisionRange;\n        this.additionalConfiguration = additionalConfiguration;\n        this.overridingConfiguration = overridingConfiguration;\n        this.overridingCensus = overridingCensus;\n\n        var utils = new Utilities();\n        commitChecks = List.of(\n            new AuthorCheck(),\n            new CommitterCheck(),\n            new WhitespaceCheck(),\n            new MergeMessageCheck(),\n            new HgTagCommitCheck(utils),\n            new DuplicateIssuesCheck(repository),\n            new ReviewersCheck(utils),\n            new MessageCheck(utils),\n            new IssuesCheck(utils),\n            new ExecutableCheck(),\n            new SymlinkCheck(),\n            new BinaryCheck(),\n            new ProblemListsCheck(repository),\n            new IssuesTitleCheck(),\n            new CopyrightFormatCheck(repository)\n        );\n        repositoryChecks = List.of(\n            new BranchesCheck(allowedBranches),\n            new TagsCheck(allowedTags)\n        );\n    }\n\n    public static Optional<JCheckConfiguration> parseConfiguration(List<String> configuration, List<String> additionalConfiguration) {\n        var content = new ArrayList<>(configuration);\n        content.addAll(additionalConfiguration);\n        if (content.size() == 0) {\n            return Optional.empty();\n        }\n        return Optional.of(JCheckConfiguration.parse(content));\n    }\n\n    public static Optional<JCheckConfiguration> parseConfiguration(ReadOnlyRepository r, Hash h, List<String> additionalConfiguration) {\n        try {\n            var content = new ArrayList<>(r.lines(Paths.get(\".jcheck/conf\"), h).orElse(Collections.emptyList()));\n            return parseConfiguration(content, additionalConfiguration);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    private Optional<JCheckConfiguration> getConfigurationFor(Commit c) {\n        if (overridingConfiguration != null) {\n            return Optional.of(overridingConfiguration);\n        }\n        return parseConfiguration(repository, c.hash(), additionalConfiguration);\n    }\n\n    private Iterator<Issue> checkCommit(Commit commit) {\n        log.fine(\"Checking: \" + commit.hash().hex());\n        var configuration = getConfigurationFor(commit);\n        if (!configuration.isPresent()) {\n            log.finer(\"No .jcheck/conf present for \" + commit.hash().hex());\n            return Collections.emptyIterator();\n        }\n\n        var conf = configuration.get();\n        var census = overridingCensus;\n        if (census == null) {\n            var uri = conf.census().url();\n            if (!censuses.containsKey(uri)) {\n                try {\n                    censuses.put(uri, Census.from(uri));\n                } catch (IOException e) {\n                    throw new UncheckedIOException(e);\n                }\n            }\n            census = censuses.get(uri);\n        }\n        var finalCensus = census;\n        var message = parser.parse(commit);\n        var availableChecks = (revisionRange.equals(STAGED_REV) || revisionRange.equals(WORKING_TREE_REV)) ? commitChecksForStagedOrWorkingTree : commitChecks;\n        var enabled = conf.checks().enabled(availableChecks);\n        var iterator = new MapIterator<>(enabled.iterator(), c -> {\n            log.finer(\"Running commit check '\" + c.name() + \"' for \" + commit.hash().hex());\n            return c.check(commit, message, conf, finalCensus);\n        });\n        return new FlatMapIterator<>(iterator);\n    }\n\n    private Set<CommitCheck> checksForCommit(Commit c) {\n        var configuration = getConfigurationFor(c);\n        if (!configuration.isPresent()) {\n            return new HashSet<>();\n        }\n\n        var conf = configuration.get();\n        return new HashSet<>(conf.checks().enabled(commitChecks));\n    }\n\n    private Set<Check> checksForRange() throws IOException {\n        if (overridingConfiguration != null) {\n            return new HashSet<>(overridingConfiguration.checks().enabled(commitChecks));\n        }\n        try (var commits = repository.commits(revisionRange)) {\n            return commits.stream()\n                          .flatMap(commit -> checksForCommit(commit).stream())\n                          .collect(Collectors.toSet());\n        }\n    }\n\n    public static class Issues implements Iterable<Issue>, AutoCloseable {\n        private final Iterator<Issue> iterator;\n        private final Closeable resource;\n\n        public Issues(Iterator<Issue> iterator,\n                      Closeable resource) {\n            this.iterator = iterator;\n            this.resource = resource;\n        }\n\n        @Override\n        public Iterator<Issue> iterator() {\n            return iterator;\n        }\n\n        public List<Issue> asList() {\n            var res = new ArrayList<Issue>();\n            for (var err : this) {\n                res.add(err);\n            }\n            return res;\n        }\n\n        public Stream<Issue> stream() {\n            return StreamSupport.stream(spliterator(), false);\n        }\n\n        @Override\n        public void close() throws IOException {\n            if (resource != null) {\n                resource.close();\n            }\n        }\n    }\n\n    private Iterator<Issue> commitIssues(Commits commits) {\n        return new FlatMapIterator<>(\n                new MapIterator<>(commits.iterator(), this::checkCommit));\n    }\n\n    private Iterator<Issue> repositoryIssues() {\n        var iterator = new MapIterator<>(repositoryChecks.iterator(), c -> {\n            log.finer(\"Running repository check '\" + c.name() + \"'\");\n            return c.check(repository);\n        });\n        return new FlatMapIterator<>(iterator);\n    }\n\n    private Issues issues() throws IOException {\n        var commits = repository.commits(revisionRange);\n\n        var repositoryIssues = repositoryIssues();\n        Iterator<Issue> commitIssues;\n        if (revisionRange.equals(STAGED_REV)) {\n            commitIssues = checkCommit(repository.staged());\n        } else if (revisionRange.equals(WORKING_TREE_REV)) {\n            commitIssues = checkCommit(repository.workingTree());\n        } else {\n            commitIssues = commitIssues(commits);\n        }\n\n        var errors = new ConcatIterator<>(repositoryIssues, commitIssues);\n        return new Issues(errors, commits);\n    }\n\n    private static Issues check(ReadOnlyRepository repository,\n                                CommitMessageParser parser,\n                                String branchRegex,\n                                String tagRegex,\n                                String revisionRange,\n                                List<String> additionalConfiguration,\n                                JCheckConfiguration configuration,\n                                Census census) throws IOException {\n\n        var defaultBranchRegex = \"|\" + repository.defaultBranch().name();\n        var allowedBranches = Pattern.compile(\"^(?:\" + branchRegex + defaultBranchRegex + \")$\");\n\n        var defaultTag = repository.defaultTag();\n        var defaultTagRegex = defaultTag.isPresent() ? \"|\" + defaultTag.get().name() : \"\";\n        var allowedTags = Pattern.compile(\"^(?:\" + tagRegex + defaultTagRegex + \")$\");\n\n        var jcheck = new JCheck(repository, parser, revisionRange, allowedBranches, allowedTags, additionalConfiguration, configuration, census);\n        return jcheck.issues();\n    }\n\n    public static Issues check(ReadOnlyRepository repository,\n                               Census census,\n                               CommitMessageParser parser,\n                               Hash toCheck,\n                               JCheckConfiguration configuration) throws IOException {\n        if (repository.isEmpty()) {\n            return new Issues(new ArrayList<Issue>().iterator(), null);\n        }\n\n        var branchRegex = configuration.repository().branches();\n        var tagRegex = configuration.repository().tags();\n\n        return check(repository, parser, branchRegex, tagRegex, repository.range(toCheck), List.of(), configuration, census);\n    }\n\n    public static Issues check(ReadOnlyRepository repository,\n                               Census census,\n                               CommitMessageParser parser,\n                               String revisionRange,\n                               JCheckConfiguration overridingConfig) throws IOException {\n        if (repository.isEmpty()) {\n            return new Issues(new ArrayList<Issue>().iterator(), null);\n        }\n\n        var defaultHead = repository.resolve(repository.defaultBranch().name());\n        var head = repository.head();\n\n        var conf = defaultHead.isPresent() ?\n            parseConfiguration(repository, defaultHead.get(), List.of()) :\n            parseConfiguration(repository, head, List.of());\n        var branchRegex = conf.isPresent() ? conf.get().repository().branches() : \".*\";\n        var tagRegex = conf.isPresent() ? conf.get().repository().tags() : \".*\";\n\n        return check(repository, parser, branchRegex, tagRegex, revisionRange, List.of(), overridingConfig, census);\n    }\n\n    public static Set<Check> checksFor(ReadOnlyRepository repository, Hash hash) throws IOException {\n        var jcheck = new JCheck(repository,\n                                CommitMessageParsers.v1,\n                                repository.range(hash),\n                                Pattern.compile(\".*\"),\n                                Pattern.compile(\".*\"),\n                                List.of(),\n                                null,\n                                null);\n        return jcheck.checksForRange();\n    }\n\n    public static Set<Check> checksFor(ReadOnlyRepository repository, JCheckConfiguration conf) throws IOException {\n        var jcheck = new JCheck(repository,\n                                CommitMessageParsers.v1,\n                                null,\n                                Pattern.compile(\".*\"),\n                                Pattern.compile(\".*\"),\n                                List.of(),\n                                conf,\n                                null);\n        return jcheck.checksForRange();\n    }\n\n    public static List<String> commitCheckNamesForStagedOrWorkingTree() {\n        return commitChecksForStagedOrWorkingTree.stream()\n                .map(Check::name)\n                .toList();\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/JCheckConfiguration.java",
    "content": "/*\n * Copyright (c) 2018, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.ini.INI;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.*;\n\npublic class JCheckConfiguration {\n    private GeneralConfiguration general;\n    private RepositoryConfiguration repository;\n    private CensusConfiguration census;\n    private ChecksConfiguration checks;\n    private String rawJCheckConf;\n\n    private JCheckConfiguration(INI ini, String rawConf) {\n        general = GeneralConfiguration.parse(ini.section(GeneralConfiguration.name()));\n        if (general.project() == null) {\n            throw new IllegalArgumentException(\"general.project must be specified\");\n        }\n        repository = RepositoryConfiguration.parse(ini.section(RepositoryConfiguration.name()));\n        census = CensusConfiguration.parse(ini.section(CensusConfiguration.name()));\n        checks = ChecksConfiguration.parse(ini.section(ChecksConfiguration.name()));\n        rawJCheckConf = rawConf;\n    }\n\n    public GeneralConfiguration general() {\n        return general;\n    }\n\n    public RepositoryConfiguration repository() {\n        return repository;\n    }\n\n    public CensusConfiguration census() {\n        return census;\n    }\n\n    public ChecksConfiguration checks() {\n        return checks;\n    }\n\n    public String rawJCheckConf() {\n        return rawJCheckConf;\n    }\n\n    private static INI convert(INI old) {\n        var project = old.get(\"project\").asString();\n        if (project == null) {\n            throw new IllegalArgumentException(\"'project' must be specified\");\n        }\n\n        var config = new ArrayList<String>();\n        config.add(\"[general]\");\n        config.add(\"project=\" + project);\n        config.add(\"jbs=JDK\");\n\n        config.add(\"[checks]\");\n        var error = \"error=author,committer,reviewers,merge,issues,executable,symlink\";\n        var shouldCheckWhitespace = false;\n        var checkWhitespace = old.get(\"whitespace\");\n        if (checkWhitespace == null || !checkWhitespace.asString().equals(\"lax\")) {\n            error += \",whitespace\";\n            shouldCheckWhitespace = true;\n        }\n        var shouldCheckMessage = false;\n        var checkMessage = old.get(\"comments\");\n        if (checkMessage == null || !checkMessage.asString().equals(\"lax\")) {\n            error += \",message,hg-tag\";\n            shouldCheckMessage = true;\n        }\n        var checkDuplicateIssues = old.get(\"bugids\");\n        if (checkDuplicateIssues == null || !checkDuplicateIssues.asString().equals(\"dup\")) {\n            error += \",duplicate-issues\";\n        }\n        config.add(error);\n\n        if (project.startsWith(\"jdk\")) {\n            config.add(\"[repository]\");\n\n            var tags = \"tags=\";\n            var checkTags = old.get(\"tags\");\n            if (checkTags == null || !checkTags.asString().equals(\"lax\")) {\n                var jdkTag = \"(?:jdk-(?:[1-9]([0-9]*)(?:\\\\.(?:0|[1-9][0-9]*)){0,4})(?:\\\\+(?:(?:[0-9]+))|(?:-ga)))\";\n                var jdkuTag = \"(?:jdk[4-9](?:u\\\\d{1,3})?-(?:(?:b\\\\d{2,3})|(?:ga)))\";\n                var hsTag = \"(?:hs\\\\d\\\\d(?:\\\\.\\\\d{1,2})?-b\\\\d\\\\d)\";\n                tags += jdkTag + \"|\" + jdkuTag + \"|\" + hsTag;\n            } else {\n                tags += \".*\";\n            }\n            config.add(tags);\n\n            var branches = \"branches=\";\n            var checkBranches = old.get(\"branches\");\n            if (checkBranches != null && checkBranches.asString().equals(\"lax\")) {\n                branches += \".*\\n\";\n            }\n            config.add(branches);\n        }\n\n        config.add(\"[census]\");\n        config.add(\"version=0\");\n        config.add(\"domain=openjdk.org\");\n\n        if (shouldCheckWhitespace) {\n            config.add(\"[checks \\\"whitespace\\\"]\");\n            config.add(\"files=.*\\\\.cpp|.*\\\\.hpp|.*\\\\.c|.*\\\\.h|.*\\\\.java\");\n        }\n\n        config.add(\"[checks \\\"merge\\\"]\");\n        config.add(\"message=Merge\");\n\n        config.add(\"[checks \\\"reviewers\\\"]\");\n        if (shouldCheckMessage) {\n            config.add(\"contributors=1\");\n        } else {\n            config.add(\"contributors=0\");\n        }\n        config.add(\"ignore=duke\");\n\n        config.add(\"[checks \\\"committer\\\"]\");\n        config.add(\"role=contributor\");\n\n        config.add(\"[checks \\\"issues\\\"]\");\n        config.add(\"pattern=^([124-8][0-9]{6}): (\\\\S.*)$\");\n        if (!shouldCheckMessage) {\n            config.add(\"required = false\");\n        }\n\n        return INI.parse(config);\n    }\n\n    public static JCheckConfiguration parse(List<String> lines) {\n        var ini = INI.parse(lines);\n        var rawConf = String.join(\"\\n\", lines);\n        if (ini.sections().size() == 0) {\n            // This is an old-style jcheck conf with only a global section -\n            // translate to new configuration style before parsing.\n            return new JCheckConfiguration(convert(ini), rawConf);\n        }\n        return new JCheckConfiguration(ini, rawConf);\n    }\n\n    public static Optional<JCheckConfiguration> from(ReadOnlyRepository r, Hash h, Path p) throws IOException {\n        return r.lines(p, h).map(lines -> parse(lines));\n    }\n\n    public static Optional<JCheckConfiguration> from(ReadOnlyRepository r, Hash h) throws IOException {\n        return from(r, h, Path.of(\".jcheck\", \"conf\"));\n    }\n\n    public static Optional<JCheckConfiguration> from(ReadOnlyRepository r) throws IOException {\n        var defaultBranch = r.defaultBranch();\n        var defaultHead = r.resolve(defaultBranch)\n                      .orElseThrow(() -> new IOException(\"Cannot resolve '\" + defaultBranch + \"' branch\"));\n        return from(r, defaultHead, Path.of(\".jcheck\", \"conf\"));\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/MergeConfiguration.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.ini.Section;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class MergeConfiguration {\n    static final MergeConfiguration DEFAULT = new MergeConfiguration(\"Merge\");\n\n    private final String message;\n\n    MergeConfiguration(String message) {\n        this.message = message;\n    }\n\n    public String message() {\n        return message;\n    }\n\n    static String name() {\n        return \"merge\";\n    }\n\n    static MergeConfiguration parse(Section s) {\n        if (s == null) {\n            return DEFAULT;\n        }\n\n        var message = s.get(\"message\", DEFAULT.message());\n        return new MergeConfiguration(message);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/MergeMessageCheck.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nimport java.util.Iterator;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\n\npublic class MergeMessageCheck extends CommitCheck {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.merge\");\n    @Override\n    Iterator<Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census) {\n        if (!commit.isMerge()) {\n            return iterator();\n        }\n\n        var pattern = Pattern.compile(conf.checks().merge().message());\n        if (!message.issues().isEmpty() || message.original().isPresent() || !pattern.matcher(message.title()).matches()) {\n            var metadata = CommitIssue.metadata(commit, message, conf, this);\n            log.finer(\"issue: wrong merge message\");\n            return iterator(new MergeMessageIssue(pattern.toString(), metadata));\n        }\n\n        return iterator();\n    }\n\n    @Override\n    public String name() {\n        return \"merge\";\n    }\n\n    @Override\n    public String description() {\n        return \"Merge commit must contain a proper message\";\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/MergeMessageIssue.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\npublic class MergeMessageIssue extends CommitIssue {\n    private final String expected;\n\n    MergeMessageIssue(String expected, CommitIssue.Metadata metadata) {\n        super(metadata);\n        this.expected = expected;\n    }\n\n    public String expected() {\n        return expected;\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/MessageCheck.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nimport java.util.ArrayList;\nimport java.util.Iterator;\nimport java.util.logging.Logger;\n\npublic class MessageCheck extends CommitCheck {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.message\");\n    private final Utilities utils;\n\n    MessageCheck(Utilities utils) {\n        this.utils = utils;\n    }\n\n    @Override\n    Iterator<Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census) {\n        var issues = new ArrayList<Issue>();\n        if (commit.isMerge() || utils.addsHgTag(commit)) {\n            return issues.iterator();\n        }\n\n        var metadata = CommitIssue.metadata(commit, message, conf, this);\n        if (commit.message().isEmpty() || !message.additional().isEmpty()) {\n            log.finer(\"issue: additional lines found in commit message for \" + commit.hash().hex());\n            issues.add(new MessageIssue(metadata));\n        }\n\n        for (var i = 0; i < commit.message().size(); i++) {\n            var line = commit.message().get(i);\n            if (line.contains(\"\\t\")) {\n                issues.add(MessageWhitespaceIssue.tab(i+1, metadata));\n            }\n            if (line.contains(\"\\r\")) {\n                issues.add(MessageWhitespaceIssue.cr(i+1, metadata));\n            }\n            if (line.endsWith(\" \")) {\n                issues.add(MessageWhitespaceIssue.trailing(i+1, metadata));\n            }\n        }\n\n        return issues.iterator();\n    }\n\n    @Override\n    public String name() {\n        return \"message\";\n    }\n\n    @Override\n    public String description() {\n        return \"Commit message must use correct syntax\";\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/MessageIssue.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\npublic class MessageIssue extends CommitIssue {\n    MessageIssue(CommitIssue.Metadata metadata) {\n        super(metadata);\n    }\n\n    @Override\n    public void accept(IssueVisitor visitor) {\n        visitor.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/MessageWhitespaceIssue.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\npublic class MessageWhitespaceIssue extends CommitIssue {\n    public static enum Whitespace {\n        TAB,\n        CR,\n        TRAILING;\n\n        public boolean isTab() {\n            return this == TAB;\n        }\n\n        public boolean isCR() {\n            return this == CR;\n        }\n\n        public boolean isTrailing() {\n            return this == TRAILING;\n        }\n    }\n\n    private final Whitespace kind;\n    private final int line;\n\n    private MessageWhitespaceIssue(CommitIssue.Metadata metadata, Whitespace kind, int line) {\n        super(metadata);\n        this.kind = kind;\n        this.line = line;\n    }\n\n    public Whitespace kind() {\n        return kind;\n    }\n\n    public int line() {\n        return line;\n    }\n\n    static MessageWhitespaceIssue tab(int line, CommitIssue.Metadata metadata) {\n        return new MessageWhitespaceIssue(metadata, Whitespace.TAB, line);\n    }\n\n    static MessageWhitespaceIssue cr(int line, CommitIssue.Metadata metadata) {\n        return new MessageWhitespaceIssue(metadata, Whitespace.CR, line);\n    }\n\n    static MessageWhitespaceIssue trailing(int line, CommitIssue.Metadata metadata) {\n        return new MessageWhitespaceIssue(metadata, Whitespace.TRAILING, line);\n    }\n\n    @Override\n    public void accept(IssueVisitor visitor) {\n        visitor.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/ProblemListsCheck.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.FileEntry;\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.ReadOnlyRepository;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.nio.file.Path;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\nimport java.util.stream.Stream;\n\npublic class ProblemListsCheck extends CommitCheck {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.problemlists\");\n    private static final Pattern WHITESPACES = Pattern.compile(\"\\\\s+\");\n\n    private final ReadOnlyRepository repo;\n\n    ProblemListsCheck(ReadOnlyRepository repo) {\n        this.repo = repo;\n    }\n\n    private Stream<String> getProblemListedIssues(Path path, Hash hash){\n        try {\n            var lines = repo.lines(path, hash);\n            if (lines.isEmpty()) {\n                return Stream.empty();\n            }\n            return lines.get()\n                        .stream()\n                        .map(String::trim)\n                        .filter(s -> !s.startsWith(\"#\"))\n                        .map(WHITESPACES::split)\n                        .filter(ss -> ss.length > 1)\n                        .map(ss -> ss[1])\n                        .flatMap(s -> Arrays.stream(s.split(\",\")));\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    Iterator<Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census) {\n        var problemListed = new HashMap<String, List<Path>>();\n        var checkConf = conf.checks().problemlists();\n        var dirs = checkConf.dirs();\n        var pattern = Pattern.compile(checkConf.pattern()).asMatchPredicate();\n        try {\n            var hash = commit.hash();\n            for (var dir : dirs.split(\"\\\\|\")) {\n                repo.files(hash, Path.of(dir))\n                    .stream()\n                    .map(FileEntry::path)\n                    .filter(p -> pattern.test(p.getFileName().toString()))\n                    .forEach(p -> getProblemListedIssues(p, commit.hash()).forEach(t -> problemListed.compute(t,\n                             (k, v) -> {if (v == null) v = new ArrayList<>(); v.add(p); return v;})));\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n\n        var metadata = CommitIssue.metadata(commit, message, conf, this);\n        var issues = new ArrayList<Issue>();\n        for (var issue : message.issues()) {\n            var problemLists = problemListed.get(issue.id());\n            if (problemLists != null) {\n                log.finer(String.format(\"issue: %s is found in problem lists: %s\", issue.id(), problemLists));\n                issues.add(new ProblemListsIssue(metadata, issue.id(), new HashSet<>(problemLists)));\n            }\n        }\n\n        return issues.iterator();\n    }\n\n    @Override\n    public String name() {\n        return \"problemlists\";\n    }\n\n    @Override\n    public String description() {\n        return \"Fixed issue should not be in a problem list\";\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/ProblemListsConfiguration.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.ini.Section;\n\npublic class ProblemListsConfiguration {\n    static final ProblemListsConfiguration DEFAULT =\n            new ProblemListsConfiguration(\"test\", \"^ProblemList.*.txt$\");\n\n    private final String dirs;\n    private final String pattern;\n\n    ProblemListsConfiguration(String dirs, String patterns) {\n        this.dirs = dirs;\n        this.pattern = patterns;\n    }\n\n    public String dirs() {\n        return dirs;\n    }\n\n    public String pattern() {\n        return pattern;\n    }\n\n    static String name() {\n        return \"problemlists\";\n    }\n\n    static ProblemListsConfiguration parse(Section s) {\n        if (s == null) {\n            return DEFAULT;\n        }\n\n        var dirs = s.get(\"dirs\", DEFAULT.dirs());\n        var pattern = s.get(\"pattern\", DEFAULT.pattern());\n        return new ProblemListsConfiguration(dirs, pattern);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/ProblemListsIssue.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport java.nio.file.Path;\nimport java.util.Set;\n\npublic class ProblemListsIssue extends CommitIssue {\n    private final String issue;\n    private final Set<Path> files;\n\n    ProblemListsIssue(CommitIssue.Metadata metadata, String issue, Set<Path> files) {\n        super(metadata);\n        this.issue = issue;\n        this.files = files;\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n\n    public String issue() {\n        return issue;\n    }\n\n    public Set<Path> files() {\n        return files;\n    }\n}\n\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/RepositoryCheck.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.ReadOnlyRepository;\n\nimport java.io.IOException;\nimport java.util.Arrays;\nimport java.util.Iterator;\n\nabstract class RepositoryCheck implements Check {\n    abstract Iterator<Issue> check(ReadOnlyRepository repository);\n\n    protected Iterator<Issue> iterator(Issue... issues) {\n        return Arrays.asList(issues).iterator();\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/RepositoryConfiguration.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.ini.Section;\n\npublic class RepositoryConfiguration {\n    private static final RepositoryConfiguration DEFAULT =\n        new RepositoryConfiguration(\".*\", \".*\");\n\n    private final String branches;\n    private final String tags;\n\n    RepositoryConfiguration(String branches, String tags) {\n        this.branches = branches;\n        this.tags = tags;\n    }\n\n    public String branches() {\n        return branches;\n    }\n\n    public String tags() {\n        return tags;\n    }\n\n    static String name() {\n        return \"repository\";\n    }\n\n    static RepositoryConfiguration parse(Section s) {\n        if (s == null) {\n            return DEFAULT;\n        }\n\n        var branches = s.get(\"branches\", DEFAULT.branches());\n        var tags = s.get(\"tags\", DEFAULT.tags());\n        return new RepositoryConfiguration(branches, tags);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/ReviewersCheck.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.census.Project;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nimport java.io.IOException;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.HashMap;\nimport java.util.stream.Collectors;\nimport java.util.logging.Logger;\n\npublic class ReviewersCheck extends CommitCheck {\n    public static final String DESCRIPTION = \"Change must be properly reviewed\";\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.reviewers\");\n    private final Utilities utils;\n\n    ReviewersCheck(Utilities utils) {\n        this.utils = utils;\n    }\n\n    private String nextRequiredRole(String role, Map<String, Integer> count) {\n        switch (role) {\n            case \"lead\":\n                return count.get(\"reviewer\") != 0 ?\n                    \"reviewer\" : nextRequiredRole(\"reviewer\", count);\n            case \"reviewer\":\n                return count.get(\"committer\") != 0 ?\n                    \"committer\" : nextRequiredRole(\"committer\", count);\n            case \"committer\":\n                return count.get(\"author\") != 0 ?\n                    \"author\" : nextRequiredRole(\"author\", count);\n            case \"author\":\n                return count.get(\"contributor\") != 0 ?\n                    \"contributor\" : nextRequiredRole(\"contributor\", count);\n            case \"contributor\":\n                return null;\n            default:\n                throw new IllegalArgumentException(\"Unexpected role: \" + role);\n        }\n    }\n\n    @Override\n    Iterator<Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census) {\n        if ((commit.isMerge() && !conf.checks().reviewers().shouldCheckMerge()) || utils.addsHgTag(commit)) {\n            return iterator();\n        }\n\n        var metadata = CommitIssue.metadata(commit, message, conf, this);\n        var project = census.project(conf.general().project());\n        var version = conf.census().version();\n        var domain = conf.census().domain();\n\n        var numLeadRole = conf.checks().reviewers().lead();\n        var numReviewerRole = conf.checks().reviewers().reviewers();\n        var numCommitterRole = conf.checks().reviewers().committers();\n        var numAuthorRole = conf.checks().reviewers().authors();\n        var numContributorRole = conf.checks().reviewers().contributors();\n\n        var ignore = conf.checks().reviewers().ignore();\n        var reviewers = message.reviewers()\n                               .stream()\n                               .filter(r -> !ignore.contains(r))\n                               .collect(Collectors.toList());\n\n        var invalid = reviewers.stream()\n                               .filter(r -> !census.isContributor(r))\n                               .collect(Collectors.toList());\n        if (!reviewers.isEmpty() && !invalid.isEmpty()) {\n            log.finer(\"issue: invalid reviewers found\");\n            return iterator(new InvalidReviewersIssue(invalid, project, metadata));\n        }\n\n        var requirements = Map.of(\n                \"lead\", numLeadRole,\n                \"reviewer\", numReviewerRole,\n                \"committer\", numCommitterRole,\n                \"author\", numAuthorRole,\n                \"contributor\", numContributorRole);\n\n        var roles = new HashMap<String, String>();\n        for (var reviewer : reviewers) {\n            String role = null;\n            if (project.isLead(reviewer, version)) {\n                role = \"lead\";\n            } else if (project.isReviewer(reviewer, version)) {\n                role = \"reviewer\";\n            } else if (project.isCommitter(reviewer, version)) {\n                role = \"committer\";\n            } else if (project.isAuthor(reviewer, version)) {\n                role = \"author\";\n            } else if (census.isContributor(reviewer)) {\n                role = \"contributor\";\n            } else {\n                throw new IllegalStateException(\"No role for reviewer: \" + reviewer);\n            }\n\n            roles.put(reviewer, role);\n        }\n\n        var missing = new HashMap<>(requirements);\n        for (var reviewer : reviewers) {\n            var role = roles.get(reviewer);\n            if (missing.get(role) == 0) {\n                var next = nextRequiredRole(role, missing);\n                if (next != null) {\n                    missing.put(next, missing.get(next) - 1);\n                }\n            } else {\n                missing.put(role, missing.get(role) - 1);\n            }\n        }\n\n        var isBackport = message.original().isPresent();\n        if (!isBackport || conf.checks().reviewers().shouldCheckBackports()) {\n            for (var role : missing.keySet()) {\n                int required = requirements.get(role);\n                int n = missing.get(role);\n                if (n > 0) {\n                    log.finer(\"issue: too few reviewers with role \" + role + \" found\");\n                    return iterator(new TooFewReviewersIssue(required - n, required, role, metadata));\n                }\n            }\n        }\n\n        var username = commit.author().name();\n        var email = commit.author().email();\n        if (email != null && email.endsWith(\"@\" + domain)) {\n            username = email.split(\"@\")[0];\n        }\n        if (reviewers.size() == 1 &&\n            reviewers.get(0).equals(username) &&\n            message.contributors().isEmpty()) {\n            log.finer(\"issue: self-review\");\n            return iterator(new SelfReviewIssue(metadata));\n        }\n\n        return iterator();\n    }\n\n    @Override\n    public String name() {\n        return \"reviewers\";\n    }\n\n    @Override\n    public String description() {\n        return DESCRIPTION;\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/ReviewersConfiguration.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.ini.Section;\n\nimport java.util.ArrayList;\nimport java.util.LinkedHashMap;\nimport java.util.List;\n\npublic class ReviewersConfiguration {\n    static final ReviewersConfiguration DEFAULT = new ReviewersConfiguration(0, 1, 0, 0, 0, List.of(\"duke\"), false, false);\n    public static final String BYLAWS_URL = \"https://openjdk.org/bylaws\";\n\n    private final int lead;\n    private final int reviewers;\n    private final int committers;\n    private final int authors;\n    private final int contributors;\n    private final List<String> ignore;\n    private final boolean shouldCheckBackports;\n    private String reviewRequirements;\n    private boolean shouldCheckMerge;\n\n    ReviewersConfiguration(int lead, int reviewers, int committers, int authors, int contributors, List<String> ignore,\n                           boolean shouldCheckBackports, boolean shouldCheckMerge) {\n        this.lead = lead;\n        this.reviewers = reviewers;\n        this.committers = committers;\n        this.authors = authors;\n        this.contributors = contributors;\n        this.ignore = ignore;\n        this.shouldCheckBackports = shouldCheckBackports;\n        this.shouldCheckMerge = shouldCheckMerge;\n    }\n\n    public int lead() {\n        return lead;\n    }\n\n    public int reviewers() {\n        return reviewers;\n    }\n\n    public int committers() {\n        return committers;\n    }\n\n    public int authors() {\n        return authors;\n    }\n\n    public int contributors() {\n        return contributors;\n    }\n\n    public List<String> ignore() {\n        return ignore;\n    }\n\n    public boolean shouldCheckBackports() {\n        return shouldCheckBackports;\n    }\n\n    public boolean shouldCheckMerge(){\n        return shouldCheckMerge;\n    }\n\n    public String getReviewRequirements() {\n        if (reviewRequirements != null && !\"\".equals(reviewRequirements)) {\n            return reviewRequirements;\n        }\n        var reviewRequirementMap = new LinkedHashMap<String, Integer>();\n        var requireList = new ArrayList<String>();\n        var sum = 0;\n        reviewRequirementMap.put(\"[Lead%s](%s#project-lead)\", lead);\n        reviewRequirementMap.put(\"[Reviewer%s](%s#reviewer)\", reviewers);\n        reviewRequirementMap.put(\"[Committer%s](%s#committer)\", committers);\n        reviewRequirementMap.put(\"[Author%s](%s#author)\", authors);\n        reviewRequirementMap.put(\"[Contributor%s](%s#contributor)\", contributors);\n        for (var reviewRequirement : reviewRequirementMap.entrySet()) {\n            var requirementNum = reviewRequirement.getValue();\n            if (requirementNum > 0) {\n                sum += requirementNum;\n                requireList.add(requirementNum + \" \" + String.format(reviewRequirement.getKey(), requirementNum > 1 ? \"s\" : \"\", BYLAWS_URL));\n            }\n        }\n        if (sum == 0) {\n            reviewRequirements = \"no review required\";\n        } else {\n            reviewRequirements = String.format(\"%d review%s required, with at least %s\",\n                    sum, sum > 1 ? \"s\" : \"\", String.join(\", \", requireList));\n        }\n        return reviewRequirements;\n    }\n\n    static String name() {\n        return \"reviewers\";\n    }\n\n    static ReviewersConfiguration parse(Section s) {\n        if (s == null) {\n            return DEFAULT;\n        }\n\n        var lead = s.get(\"lead\", 0);\n        var reviewers = s.get(\"reviewers\", 0);\n        var committers = s.get(\"committers\", 0);\n        var authors = s.get(\"authors\", 0);\n        var contributors = s.get(\"contributors\", 0);\n\n        if (s.contains(\"minimum\")) {\n            var isMinimumDisabled = s.get(\"minimum\").asString().trim().toLowerCase().equals(\"disable\");\n            if (!isMinimumDisabled) {\n                for (var role : List.of(\"lead\", \"reviewers\", \"committers\", \"authors\", \"contributors\")) {\n                    if (s.contains(role)) {\n                        throw new IllegalStateException(\"Cannot combine 'minimum' with '\" + role + \"'\");\n                    }\n                }\n\n                // Reset defaults to 0\n                lead = 0;\n                reviewers = 0;\n                committers = 0;\n                authors = 0;\n                contributors = 0;\n\n                var minimum = s.get(\"minimum\").asInt();\n                if (s.contains(\"role\")) {\n                    var role = s.get(\"role\").asString();\n                    if (role.equals(\"lead\")) {\n                        lead = minimum;\n                    } else if (role.equals(\"reviewer\")) {\n                        reviewers = minimum;\n                    } else if (role.equals(\"committer\")) {\n                        committers = minimum;\n                    } else if (role.equals(\"author\")) {\n                        authors = minimum;\n                    } else if (role.equals(\"contributor\")) {\n                        contributors = minimum;\n                    } else {\n                        throw new IllegalArgumentException(\"Unexpected role: \" + role);\n                    }\n                } else {\n                    reviewers = minimum;\n                }\n            }\n        }\n\n        var ignore = s.get(\"ignore\", DEFAULT.ignore());\n        var shouldCheckBackports = s.get(\"backports\", \"ignore\").equals(\"check\");\n        var shouldCheckMerge = s.get(\"merge\", \"ignore\").equals(\"check\");\n\n        return new ReviewersConfiguration(lead, reviewers, committers, authors, contributors, ignore, shouldCheckBackports, shouldCheckMerge);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/SelfReviewIssue.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\npublic class SelfReviewIssue extends CommitIssue {\n    SelfReviewIssue(CommitIssue.Metadata metadata) {\n        super(metadata);\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/Severity.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\npublic enum Severity {\n    ERROR,\n    WARNING;\n\n    @Override\n    public String toString() {\n        switch (this) {\n            case ERROR:\n                return \"error\";\n            case WARNING:\n                return \"warning\";\n            default:\n                throw new IllegalArgumentException();\n        }\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/SymlinkCheck.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nimport java.util.Iterator;\nimport java.util.ArrayList;\nimport java.util.logging.Logger;\n\npublic class SymlinkCheck extends CommitCheck {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.symlink\");\n\n    @Override\n    Iterator<Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census) {\n        var metadata = CommitIssue.metadata(commit, message, conf, this);\n\n        var issues = new ArrayList<Issue>();\n        for (var diff : commit.parentDiffs()) {\n            for (var patch : diff.patches()) {\n                if (patch.target().type().isPresent()) {\n                    var type = patch.target().type().get();\n                    if (type.isSymbolicLink()) {\n                        var path = patch.target().path().get();\n                        log.finer(\"issue: \" + path + \" is symbolic link\");\n                        issues.add(new SymlinkIssue(path, metadata));\n                    }\n                }\n            }\n        }\n\n        return issues.iterator();\n    }\n\n    @Override\n    public String name() {\n        return \"symlink\";\n    }\n\n    @Override\n    public String description() {\n        return \"Files should not be symbolic links\";\n    }\n}\n\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/SymlinkIssue.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport java.nio.file.Path;\n\npublic class SymlinkIssue extends CommitIssue {\n    private final Path path;\n\n    SymlinkIssue(Path path, CommitIssue.Metadata metadata) {\n        super(metadata);\n        this.path = path;\n    }\n\n    public Path path() {\n        return path;\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n}\n\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/TagIssue.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.Tag;\n\npublic class TagIssue extends Issue {\n    private final Tag tag;\n\n    public TagIssue(Tag tag, Check check) {\n        super(Severity.ERROR, check);\n\n        this.tag = tag;\n    }\n\n    public Tag tag() {\n        return tag;\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/TagsCheck.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.Tag;\nimport org.openjdk.skara.vcs.ReadOnlyRepository;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.util.regex.Pattern;\nimport java.util.Iterator;\nimport java.util.logging.Logger;\n\npublic class TagsCheck extends RepositoryCheck {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.tags\");\n    private final Pattern allowed;\n\n    TagsCheck(Pattern allowed) {\n        this.allowed = allowed;\n    }\n\n    private boolean isAllowed(Tag t, ReadOnlyRepository repo) {\n        try {\n            var defaultTag = repo.defaultTag();\n            return (defaultTag.isPresent() && defaultTag.get().equals(t)) ||\n                    allowed.matcher(t.name()).matches();\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    Iterator<Issue> check(ReadOnlyRepository repo) {\n        log.finer(\"Allowed tags: \" + allowed.toString());\n        try {\n            return repo.tags()\n                       .stream()\n                       .filter(t -> !isAllowed(t, repo))\n                       .map(t -> (Issue) new TagIssue(t, this))\n                       .iterator();\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public String name() {\n        return \"tags\";\n    }\n\n    @Override\n    public String description() {\n        return \"Tag names must use correct syntax\";\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/TooFewReviewersIssue.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\npublic class TooFewReviewersIssue extends CommitIssue {\n    private final int numActual;\n    private final int numRequired;\n    private final String role;\n\n    TooFewReviewersIssue(int numActual, int numRequired, String role, CommitIssue.Metadata metadata) {\n        super(metadata);\n        this.numActual = numActual;\n        this.numRequired = numRequired;\n        this.role = role;\n    }\n\n    public int numRequired() {\n        return numRequired;\n    }\n\n    public int numActual() {\n        return numActual;\n    }\n\n    public String role() {\n        return role;\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/Utilities.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.util.HashSet;\nimport java.util.Set;\n\nclass Utilities {\n    private final Set<Hash> addsHgTagCache = new HashSet<>();\n\n    boolean addsHgTag(Commit commit) {\n        if (addsHgTagCache.contains(commit.hash())) {\n            return true;\n        }\n        for (var diff : commit.parentDiffs()) {\n            for (var patch : diff.patches()) {\n                if (!patch.target().path().isPresent() || patch.isBinary()) {\n                    continue;\n                }\n                if (patch.target().path().get().endsWith(\".hgtags\") ||\n                    patch.target().path().get().endsWith(\".hgtags-top-repo\")) {\n                    for (var hunk : patch.asTextualPatch().hunks()) {\n                        var removed = new HashSet<>(hunk.source().lines());\n                        var added = new HashSet<>(hunk.target().lines());\n                        added.removeAll(removed);\n                        if (added.size() > 0) {\n                            addsHgTagCache.add(commit.hash());\n                            return true;\n                        }\n                    }\n                }\n            }\n        }\n\n        return false;\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/WhitespaceCheck.java",
    "content": "/*\n * Copyright (c) 2018, 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\n\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.logging.Logger;\n\npublic class WhitespaceCheck extends CommitCheck {\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.jcheck.whitespace\");\n\n    @Override\n    Iterator<Issue> check(Commit commit, CommitMessage message, JCheckConfiguration conf, Census census) {\n        var metadata = CommitIssue.metadata(commit, message, conf, this);\n        var issues = new ArrayList<Issue>();\n        var pattern = Pattern.compile(conf.checks().whitespace().files());\n        var tabPattern = Pattern.compile(conf.checks().whitespace().ignoreTabs());\n\n        for (var diff : commit.parentDiffs()) {\n            for (var patch : diff.patches()) {\n                if (!patch.target().path().isPresent() || patch.isBinary()) {\n                    continue;\n                }\n                var path = patch.target().path().get();\n                if (pattern.matcher(path.toString()).matches()) {\n                    for (var hunk : patch.asTextualPatch().hunks()) {\n                        var lines = hunk.target().lines();\n                        for (var i = 0; i < lines.size(); i++) {\n                            var line = lines.get(i);\n                            var row = hunk.target().range().start() + i;\n                            var tabIndex = line.indexOf('\\t');\n                            var crIndex = line.indexOf('\\r');\n                            var ignoreTab = tabPattern.matcher(path.toString()).matches();\n                            if ((tabIndex >= 0 && !ignoreTab) || crIndex >= 0\n                                    || line.endsWith(\" \") || line.endsWith(\"\\t\")) {\n                                var errors = new ArrayList<WhitespaceIssue.Error>();\n                                var trailing = true;\n                                for (var index = line.length() - 1; index >= 0; index--) {\n                                    if ((line.charAt(index) == ' ' || line.charAt(index) == '\\t') && trailing) {\n                                        errors.add(WhitespaceIssue.trailing(index));\n                                    } else if (line.charAt(index) == '\\t'  && !ignoreTab) {\n                                        errors.add(WhitespaceIssue.tab(index));\n                                    } else if (line.charAt(index) == '\\r') {\n                                        errors.add(WhitespaceIssue.cr(index));\n                                    } else {\n                                        trailing = false;\n                                    }\n                                }\n                                Collections.reverse(errors);\n                                issues.add(new WhitespaceIssue(path, line, row, errors, metadata));\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        return issues.iterator();\n    }\n\n    @Override\n    public String name() {\n        return \"whitespace\";\n    }\n\n    @Override\n    public String description() {\n        return \"Change must not contain extraneous whitespace\";\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/WhitespaceConfiguration.java",
    "content": "/*\n * Copyright (c) 2019, 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.ini.Section;\n\npublic class WhitespaceConfiguration {\n    static final WhitespaceConfiguration DEFAULT =\n        new WhitespaceConfiguration(\".*\\\\.cpp|.*\\\\.hpp|.*\\\\.c|.*\\\\.h|.*\\\\.java\", \"\");\n\n    private final String files;\n    private final String ignoreTabs;\n\n    WhitespaceConfiguration(String files, String ignoreTabs) {\n        this.files = files;\n        this.ignoreTabs = ignoreTabs;\n    }\n\n    public String files() {\n        return files;\n    }\n\n    public String ignoreTabs() {\n        return ignoreTabs;\n    }\n\n    static String name() {\n        return \"whitespace\";\n    }\n\n    static WhitespaceConfiguration parse(Section s) {\n        if (s == null) {\n            return DEFAULT;\n        }\n\n        var files = s.get(\"files\", DEFAULT.files());\n        var ignoreTabs = s.get(\"ignore-tabs\", DEFAULT.ignoreTabs());\n        return new WhitespaceConfiguration(files, ignoreTabs);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/WhitespaceIssue.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport java.nio.file.Path;\nimport java.util.*;\n\npublic class WhitespaceIssue extends CommitIssue {\n    public static enum Whitespace {\n        TAB,\n        CR,\n        TRAILING;\n\n        @Override\n        public String toString() {\n            switch (this) {\n                case TAB:\n                    return \"tab\";\n                case CR:\n                    return \"carriage return (^M)\";\n                case TRAILING:\n                    return \"trailing whitespace\";\n                default:\n                    throw new IllegalArgumentException();\n            }\n        }\n    }\n\n    public static class Error {\n        private final int index;\n        private final Whitespace kind;\n\n        public Error(int index, Whitespace kind) {\n            this.index = index;\n            this.kind = kind;\n        }\n\n        public int index() {\n            return index;\n        }\n\n        public Whitespace kind() {\n            return kind;\n        }\n\n        @Override\n        public String toString() {\n            return index + \": \" + kind.toString();\n        }\n\n        @Override\n        public int hashCode() {\n            return Objects.hash(index, kind);\n        }\n\n        @Override\n        public boolean equals(Object other) {\n            if (other == this) {\n                return true;\n            }\n\n            if (!(other instanceof Error o)) {\n                return false;\n            }\n\n            return Objects.equals(index, o.index) &&\n                   Objects.equals(kind, o.kind);\n        }\n    }\n\n    private final Path path;\n    private final String line;\n    private final int row;\n    private final List<Error> errors;\n\n    WhitespaceIssue(Path path, String line, int row, List<Error> errors, CommitIssue.Metadata metadata) {\n        super(metadata);\n        this.path = path;\n        this.line = line;\n        this.row = row;\n        this.errors = errors;\n    }\n\n    public Path path() {\n        return path;\n    }\n\n    public String line() {\n        return line;\n    }\n\n    public int row() {\n        return row;\n    }\n\n    public List<Error> errors() {\n        return errors;\n    }\n\n    @Override\n    public void accept(IssueVisitor v) {\n        v.visit(this);\n    }\n\n    static Error tab(int index) {\n        return new Error(index, Whitespace.TAB);\n    }\n\n    static Error cr(int index) {\n        return new Error(index, Whitespace.CR);\n    }\n\n    static Error trailing(int index) {\n        return new Error(index, Whitespace.TRAILING);\n    }\n\n    private String join(List<String> words) {\n        switch (words.size()) {\n            case 0:\n                return \"\";\n            case 1:\n                return words.get(0);\n            case 2:\n                return words.get(0) + \" and \" + words.get(1);\n            default:\n                var commaSeparated = String.join(\", \", words.subList(0, words.size() - 1));\n                return commaSeparated + \" and \" + words.get(words.size() - 1);\n        }\n    }\n\n    public String describe() {\n        int[] counts = new int[3];\n        for (var error : errors) {\n            if (error.kind() == Whitespace.TAB) {\n                counts[0]++;\n            } else if (error.kind() == Whitespace.CR) {\n                counts[1]++;\n            } else {\n                counts[2]++;\n            }\n        }\n\n        var description = new ArrayList<String>();\n        if (counts[0] == 1) {\n            description.add(\"tab\");\n        } else if (counts[0] > 1) {\n            description.add(\"tabs\");\n        }\n\n        if (counts[1] == 1) {\n            description.add(\"carriage return (^M)\");\n        } else if (counts[1] > 1) {\n            description.add(\"carriage returns (^M)\");\n        }\n\n        if (counts[2] > 0) {\n            description.add(\"trailing whitespace\");\n        }\n\n        return join(description);\n    }\n\n    public String escapeLine() {\n        return line.replaceAll(\"\\\\t\", \"    \").replaceAll(\"\\\\r\", \"^M\");\n    }\n\n    public String hints() {\n        var hints = new StringBuilder();\n        var trailing = true;\n        for (var i = line.length() - 1; i >= 0; i--) {\n            var c = line.charAt(i);\n            if (c == ' ' && trailing) {\n                hints.append(\"^\");\n            } else if (c == '\\t') {\n                hints.append(\"^^^^\"); // tab is escaped to 4 chars\n            } else if (c == '\\r') {\n                hints.append(\"^^\");   // cr is escaped to 2 chars\n            } else {\n                trailing = false;\n                hints.append(\" \");\n            }\n        }\n\n        return hints.reverse().toString();\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/iterators/ConcatIterator.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck.iterators;\n\nimport java.util.Iterator;\n\npublic class ConcatIterator<T> implements Iterator<T> {\n    private final Iterator<T>[] iterators;\n\n    @SafeVarargs\n    @SuppressWarnings(\"varargs\")\n    public ConcatIterator(Iterator<T>... iterators) {\n        this.iterators = iterators;\n    }\n\n    @Override\n    public boolean hasNext() {\n        for (var i : iterators) {\n            if (i.hasNext()) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    @Override\n    public T next() {\n        for (var i : iterators) {\n            if (i.hasNext()) {\n                return i.next();\n            }\n        }\n        return null;\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/iterators/FlatMapIterator.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck.iterators;\n\nimport java.util.Iterator;\n\npublic class FlatMapIterator<T> implements Iterator<T> {\n    private final Iterator<Iterator<T>> ii;\n    private Iterator<T> i;\n\n    public FlatMapIterator(Iterator<Iterator<T>> ii) {\n        this.ii = ii;\n        this.i = null;\n    }\n\n    @Override\n    public boolean hasNext() {\n        if (i != null && i.hasNext()) {\n            return true;\n        }\n\n        if (!ii.hasNext()) {\n            return false;\n        }\n\n        i = ii.next();\n        while (!i.hasNext() && ii.hasNext()) {\n            i = ii.next();\n        }\n\n        return i.hasNext();\n    }\n\n    @Override\n    public T next() {\n        if (i == null) {\n            return null;\n        }\n\n        return i.next();\n    }\n}\n"
  },
  {
    "path": "jcheck/src/main/java/org/openjdk/skara/jcheck/iterators/MapIterator.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck.iterators;\n\nimport java.util.Iterator;\nimport java.util.function.Function;\n\npublic class MapIterator<S, T> implements Iterator<T> {\n    private final Iterator<S> source;\n    private final Function<S, T> f;\n\n    public MapIterator(Iterator<S> source, Function<S, T> f) {\n        this.source = source;\n        this.f = f;\n    }\n\n    @Override\n    public boolean hasNext() {\n        return source.hasNext();\n    }\n\n    @Override\n    public T next() {\n        if (source.hasNext()) {\n            return f.apply(source.next());\n        }\n\n        return null;\n    }\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/AuthorCheckTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport java.util.*;\nimport java.time.ZonedDateTime;\nimport java.io.IOException;\n\nclass AuthorCheckTests {\n    private static final JCheckConfiguration conf = JCheckConfiguration.parse(List.of(\n        \"[general]\",\n        \"project = test\",\n        \"[checks]\",\n        \"error = author\"\n    ));\n\n    private static Commit commit(Author author) {\n        var committer = new Author(\"Foo\", \"foo@bar.org\");\n        var committed = ZonedDateTime.now();\n        var hash = new Hash(\"0123456789012345678901234567890123456789\");\n        var parents = List.of(new Hash(\"12345789012345789012345678901234567890\"));\n        var authored = ZonedDateTime.now();\n        var message = List.of(\"Initial commit\");\n        var metadata = new CommitMetadata(hash, parents, author, authored, committer, committed, message);\n        return new Commit(metadata, List.of());\n    }\n\n    private static CommitMessage message(Commit c) {\n        return CommitMessageParsers.v1.parse(c);\n    }\n\n    private List<Issue> toList(Iterator<Issue> i) {\n        var list = new ArrayList<Issue>();\n        while (i.hasNext()) {\n            list.add(i.next());\n        }\n        return list;\n    }\n\n    @Test\n    void commitWithAuthorNameAndEmailShouldPass() throws IOException {\n        var author = new Author(\"Foo\", \"foo@localhost\");\n        var commit = commit(author);\n        var check = new AuthorCheck();\n        var issues = toList(check.check(commit, message(commit), conf, null));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void missingAuthorNameShouldFail() throws IOException {\n        var author = new Author(\"\", \"foo@localhost\");\n        var commit = commit(author);\n        var message = message(commit);\n        var check = new AuthorCheck();\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof AuthorNameIssue);\n        var issue = (AuthorNameIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(check, issue.check());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n\n    @Test\n    void nullAuthorNameShouldFail() throws IOException {\n        var author = new Author(null, \"foo@localhost\");\n        var commit = commit(author);\n        var message = message(commit);\n        var check = new AuthorCheck();\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof AuthorNameIssue);\n        var issue = (AuthorNameIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(check, issue.check());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n\n    @Test\n    void missingAuthorEmailShouldFail() throws IOException {\n        var author = new Author(\"Foo\", \"\");\n        var commit = commit(author);\n        var message = message(commit);\n        var check = new AuthorCheck();\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof AuthorEmailIssue);\n        var issue = (AuthorEmailIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(check, issue.check());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n\n    @Test\n    void nullAuthorEmailShouldFail() throws IOException {\n        var author = new Author(\"Foo\", null);\n        var commit = commit(author);\n        var message = message(commit);\n        var check = new AuthorCheck();\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof AuthorEmailIssue);\n        var issue = (AuthorEmailIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(check, issue.check());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/BinaryCheckTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport java.nio.file.Path;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.ArrayList;\nimport java.time.ZonedDateTime;\nimport java.io.IOException;\n\nclass BinaryCheckTests {\n    private static final JCheckConfiguration conf = JCheckConfiguration.parse(List.of(\n        \"[general]\",\n        \"project = test\",\n        \"[checks]\",\n        \"error = binary\"\n    ));\n\n    private static List<Diff> textualParentDiffs(String filename, String mode) {\n        var hunk = new Hunk(new Range(1, 0), List.of(),\n                            new Range(1, 1), List.of(\"An additional line\"));\n        var patch = new TextualPatch(Path.of(filename), FileType.fromOctal(\"100644\"), Hash.zero(),\n                                     Path.of(filename), FileType.fromOctal(mode), Hash.zero(),\n                                     Status.from('M'), List.of(hunk));\n        var diff = new Diff(Hash.zero(), Hash.zero(), List.of(patch));\n        return List.of(diff);\n    }\n\n    private static Commit commit(List<Diff> parentDiffs) {\n        var author = new Author(\"foo\", \"foo@host.org\");\n        var hash = new Hash(\"0123456789012345678901234567890123456789\");\n        var parents = List.of(hash, hash);\n        var message = List.of(\"A commit\");\n        var authored = ZonedDateTime.now();\n        var metadata = new CommitMetadata(hash, parents, author, authored, author, authored, message);\n        return new Commit(metadata, parentDiffs);\n    }\n\n    private List<Issue> toList(Iterator<Issue> i) {\n        var list = new ArrayList<Issue>();\n        while (i.hasNext()) {\n            list.add(i.next());\n        }\n        return list;\n    }\n\n    private static CommitMessage message(Commit c) {\n        return CommitMessageParsers.v1.parse(c);\n    }\n\n    @Test\n    void regularFileShouldPass() throws IOException {\n        var commit = commit(textualParentDiffs(\"README\", \"100644\"));\n        var message = message(commit);\n        var check = new BinaryCheck();\n        var issues = toList(check.check(commit, message, conf, null));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void binaryFileShouldFail() throws IOException {\n        var hunk = BinaryHunk.ofLiteral(8, List.of(\"asdfasdf8\"));\n        var patch = new BinaryPatch(null, null, null,\n                                    Path.of(\"file.bin\"), FileType.fromOctal(\"100644\"), Hash.zero(),\n                                    Status.from('A'), List.of(hunk));\n        var diff = new Diff(Hash.zero(), Hash.zero(), List.of(patch));\n        var commit = commit(List.of(diff));\n        var message = message(commit);\n        var check = new BinaryCheck();\n        var issues = toList(check.check(commit, message, conf, null));\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof BinaryIssue);\n        var issue = (BinaryIssue) issues.get(0);\n        assertEquals(Path.of(\"file.bin\"), issue.path());\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(check, issue.check());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/BranchesCheckTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.Branch;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport java.io.IOException;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.ArrayList;\nimport java.util.regex.Pattern;\n\nclass BranchesCheckTests {\n    private List<Issue> toList(Iterator<Issue> i) {\n        var list = new ArrayList<Issue>();\n        while (i.hasNext()) {\n            list.add(i.next());\n        }\n        return list;\n    }\n\n    @Test\n    void onlyDefaultBranchShouldPass() throws IOException {\n        var repo = new TestRepository();\n        repo.setDefaultBranch(new Branch(\"master\"));\n        repo.setBranches(List.of(new Branch(\"master\")));\n\n        var allowNothing = Pattern.compile(\"\");\n        var check = new BranchesCheck(allowNothing);\n        var issues = toList(check.check(repo));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void allowedBranchShouldPass() throws IOException {\n        var repo = new TestRepository();\n        var allowed = \"foo\";\n        repo.setDefaultBranch(new Branch(\"master\"));\n        repo.setBranches(List.of(new Branch(\"master\"), new Branch(allowed)));\n\n        var check = new BranchesCheck(Pattern.compile(allowed));\n        var issues = toList(check.check(repo));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void additionalBranchShouldFail() throws IOException {\n        var repo = new TestRepository();\n        repo.setDefaultBranch(new Branch(\"master\"));\n        var additional = new Branch(\"foo\");\n        repo.setBranches(List.of(new Branch(\"master\"), additional));\n\n        var allowNothing = Pattern.compile(\"\");\n        var check = new BranchesCheck(allowNothing);\n        var issues = toList(check.check(repo));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof BranchIssue);\n        var issue = (BranchIssue) issues.get(0);\n        assertEquals(additional, issue.branch());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(BranchesCheck.class, issue.check().getClass());\n    }\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/CommitterCheckTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.Author;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.CommitMetadata;\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.ArrayList;\nimport java.time.ZonedDateTime;\nimport java.io.IOException;\n\nclass CommitterCheckTests {\n    private static final List<String> CENSUS = List.of(\n        \"<?xml version=\\\"1.0\\\" encoding=\\\"us-ascii\\\"?>\",\n        \"<census time=\\\"2019-03-13T10:29:41-07:00\\\">\",\n        \"  <person name=\\\"foo\\\">\",\n        \"    <full-name>Foo</full-name>\",\n        \"  </person>\",\n        \"  <person name=\\\"bar\\\">\",\n        \"    <full-name>Bar</full-name>\",\n        \"  </person>\",\n        \"  <person name=\\\"baz\\\">\",\n        \"    <full-name>Baz</full-name>\",\n        \"  </person>\",\n        \"  <group name=\\\"test\\\">\",\n        \"    <full-name>Test</full-name>\",\n        \"    <person ref=\\\"foo\\\" role=\\\"lead\\\" />\",\n        \"    <person ref=\\\"bar\\\" />\",\n        \"    <person ref=\\\"baz\\\" />\",\n        \"  </group>\",\n        \"  <project name=\\\"test\\\">\",\n        \"    <full-name>Test</full-name>\",\n        \"    <sponsor ref=\\\"test\\\" />\",\n        \"    <person role=\\\"lead\\\" ref=\\\"foo\\\" />\",\n        \"    <person role=\\\"committer\\\" ref=\\\"bar\\\" />\",\n        \"    <person role=\\\"author\\\" ref=\\\"baz\\\" />\",\n        \"  </project>\",\n        \"</census>\"\n    );\n\n    private static final List<String> CONFIGURATION = List.of(\n        \"[general]\",\n        \"project = test\",\n        \"[checks]\",\n        \"error = committer\"\n    );\n\n    private static Commit mergeCommit(Author author, Author committer) {\n        var hash = new Hash(\"0123456789012345678901234567890123456789\");\n        var parents = List.of(hash, hash);\n        var date = ZonedDateTime.now();\n        var message = List.of(\"Merge\");\n        var metadata = new CommitMetadata(hash, parents, author, date, committer, date, message);\n        return new Commit(metadata, List.of());\n    }\n\n    private static Commit commit(Author author, Author committer) {\n        var hash = new Hash(\"0123456789012345678901234567890123456789\");\n        var parents = List.of(new Hash(\"12345789012345789012345678901234567890\"));\n        var date = ZonedDateTime.now();\n        var message = List.of(\"Initial commit\");\n        var metadata = new CommitMetadata(hash, parents, author, date, committer, date, message);\n        return new Commit(metadata, List.of());\n    }\n\n    private static CommitMessage message(Commit c) {\n        return CommitMessageParsers.v1.parse(c);\n    }\n\n    private static Census census() throws IOException {\n        return Census.parse(CENSUS);\n    }\n\n    private static JCheckConfiguration conf() throws IOException {\n        return JCheckConfiguration.parse(CONFIGURATION);\n    }\n\n    private List<Issue> toList(Iterator<Issue> i) {\n        var list = new ArrayList<Issue>();\n        while (i.hasNext()) {\n            list.add(i.next());\n        }\n        return list;\n    }\n\n    @Test\n    void authorIsLeadShouldPass() throws IOException {\n        var author = new Author(\"Foo\", \"foo@localhost\");\n        var commit = commit(author, author);\n        var check = new CommitterCheck();\n        var issues = toList(check.check(commit, message(commit), conf(), census()));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void authorIsCommitterShouldPass() throws IOException {\n        var author = new Author(\"Bar\", \"bar@localhost\");\n        var commit = commit(author, author);\n        var check = new CommitterCheck();\n        var issues = toList(check.check(commit, message(commit), conf(), census()));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void authorIsAuthorShouldNotWork() throws IOException {\n        var author = new Author(\"Baz\", \"baz@localhost\");\n        var commit = commit(author, author);\n        var message = message(commit);\n        var check = new CommitterCheck();\n        var issues = toList(check.check(commit, message, conf(), census()));\n\n        assertEquals(1, issues.size());\n        var issue = issues.get(0);\n        assertTrue(issue instanceof CommitterIssue);\n        var committerIssue = (CommitterIssue) issue;\n        assertEquals(\"test\", committerIssue.project().name());\n        assertEquals(commit, committerIssue.commit());\n        assertEquals(CommitterCheck.class, committerIssue.check().getClass());\n        assertEquals(message, committerIssue.message());\n        assertEquals(Severity.ERROR, committerIssue.severity());\n    }\n\n    @Test\n    void unknownAuthorAndCommitterShouldFail() throws IOException {\n        var author = new Author(\"Foo\", \"foo@host.org\");\n        var committer = new Author(\"Bar\", \"bar@host.org\");\n        var commit = commit(author, committer);\n        var message = message(commit);\n        var check = new CommitterCheck();\n        var issues = toList(check.check(commit, message, conf(), census()));\n\n        assertEquals(1, issues.size());\n        var issue = issues.get(0);\n        assertTrue(issue instanceof CommitterEmailIssue);\n        var committerIssue = (CommitterEmailIssue) issue;\n        assertEquals(\"localhost\", committerIssue.expectedDomain());\n        assertEquals(commit, committerIssue.commit());\n        assertEquals(check, committerIssue.check());\n        assertEquals(message, committerIssue.message());\n        assertEquals(Severity.ERROR, committerIssue.severity());\n    }\n\n    @Test\n    void unknownAuthorAndKnownCommitterShouldPass() throws IOException {\n        var author = new Author(\"Foo\", \"foo@host.org\");\n        var committer = new Author(\"bar\", \"bar@localhost\");\n        var commit = commit(author, committer);\n        var message = message(commit);\n        var check = new CommitterCheck();\n        var issues = toList(check.check(commit, message, conf(), census()));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void unknownAuthorAndKnownAuthorShouldFail() throws IOException {\n        var author = new Author(\"Foo\", \"foo@host.org\");\n        var committer = new Author(\"Baz\", \"baz@localhost\");\n        var commit = commit(author, committer);\n        var message = message(commit);\n        var check = new CommitterCheck();\n        var issues = toList(check.check(commit, message, conf(), census()));\n\n        assertEquals(1, issues.size());\n        var issue = issues.get(0);\n        assertTrue(issue instanceof CommitterIssue);\n        var committerIssue = (CommitterIssue) issue;\n        assertEquals(\"test\", committerIssue.project().name());\n        assertEquals(commit, committerIssue.commit());\n        assertEquals(CommitterCheck.class, committerIssue.check().getClass());\n        assertEquals(message, committerIssue.message());\n        assertEquals(Severity.ERROR, committerIssue.severity());\n    }\n\n    @Test\n    void missingCommitterNameShouldFail() throws IOException {\n        var author = new Author(\"Foo\", \"foo@host.org\");\n        var committer = new Author(\"\", \"baz@localhost\");\n        var commit = commit(author, committer);\n        var message = message(commit);\n        var check = new CommitterCheck();\n        var issues = toList(check.check(commit, message, conf(), census()));\n\n        assertEquals(2, issues.size());\n        assertTrue(issues.get(0) instanceof CommitterNameIssue);\n        var issue = (CommitterNameIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(check, issue.check());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n\n    @Test\n    void missingCommitterEmailShouldFail() throws IOException {\n        var author = new Author(\"Foo\", \"foo@host.org\");\n        var committer = new Author(\"Baz\", \"\");\n        var commit = commit(author, committer);\n        var message = message(commit);\n        var check = new CommitterCheck();\n        var issues = toList(check.check(commit, message, conf(), census()));\n\n        assertEquals(2, issues.size());\n        assertTrue(issues.get(0) instanceof CommitterEmailIssue);\n        var issue = (CommitterEmailIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(check, issue.check());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n\n    @Test\n    void allowedToMerge() throws IOException {\n        var author = new Author(\"baz\", \"baz@localhost\");\n        var committer = new Author(\"baz\", \"baz@localhost\");\n        var commit = mergeCommit(author, committer);\n        var message = message(commit);\n        var check = new CommitterCheck();\n        var issues = toList(check.check(commit, message, conf(), census()));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof CommitterIssue);\n\n        check = new CommitterCheck();\n        var text = new ArrayList<>(CONFIGURATION);\n        text.addAll(List.of(\"[checks \\\"committer\\\"]\", \"allowed-to-merge=baz\"));\n        var conf = JCheckConfiguration.parse(text);\n        issues = toList(check.check(commit, message, conf, census()));\n        assertEquals(List.of(), issues);\n    }\n\n    @Test\n    void allowedToMergeShouldOnlyWorkForMergeCommits() throws IOException {\n        var author = new Author(\"baz\", \"baz@localhost\");\n        var committer = new Author(\"baz\", \"baz@localhost\");\n        var commit = commit(author, committer);\n        var message = message(commit);\n        var check = new CommitterCheck();\n        var text = new ArrayList<>(CONFIGURATION);\n        text.addAll(List.of(\"[checks \\\"committer\\\"]\", \"allowed-to-merge=baz\"));\n        var conf = JCheckConfiguration.parse(text);\n        var issues = toList(check.check(commit, message, conf, census()));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof CommitterIssue);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/CopyrightFormatCheckTests.java",
    "content": "/*\n * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestableRepository;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.VCS;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class CopyrightFormatCheckTests {\n\n    private static JCheckConfiguration conf() {\n        return JCheckConfiguration.parse(List.of(\n                \"[general]\",\n                \"project = test\",\n                \"[checks]\",\n                \"error = copyright\",\n                \"[checks \\\"copyright\\\"]\",\n                \"files=.*\\\\.cpp|.*\\\\.hpp|.*\\\\.c|.*\\\\.h|.*\\\\.java|.*\\\\.cc|.*\\\\.hh\",\n                \"oracle_locator=.*Copyright \\\\(c\\\\)(.*)Oracle and/or its affiliates\\\\. All rights reserved\\\\.\",\n                \"oracle_validator=.*Copyright \\\\(c\\\\) (\\\\d{4})(?:, (\\\\d{4}))?, Oracle and/or its affiliates\\\\. All rights reserved\\\\.\",\n                \"oracle_required=true\",\n                \"redhat_locator=.*Copyright \\\\(c\\\\)(.*)Red Hat, Inc\\\\.\",\n                \"redhat_validator=.*Copyright \\\\(c\\\\) (\\\\d{4})(?:, (\\\\d{4}))?, Red Hat, Inc\\\\.\"\n        ));\n    }\n\n    private static CommitMessage message(Commit c) {\n        return CommitMessageParsers.v1.parse(c);\n    }\n\n    @Test\n    void CopyrightFormatIssueWithTrailingWhiteSpace() throws IOException {\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), VCS.GIT);\n\n            var afile = dir.path().resolve(\"a.java\");\n            Files.write(afile, List.of(\"/*\\n\" +\n                    \" * Copyright (c) 2024,  Oracle and/or its affiliates. All rights reserved.\\n\" +\n                    \" * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\\n\" +\n                    \" */\\n\"));\n            r.add(afile);\n            var first = r.commit(\"1: Added a.java\", \"duke\", \"duke@openjdk.org\");\n\n            var check = new CopyrightFormatCheck(r);\n            var commit = r.lookup(first).orElseThrow();\n            var issue = (CopyrightFormatIssue) check.check(commit, message(commit), conf(), null).next();\n            assertEquals(1, issue.filesWithCopyrightFormatIssue.size());\n            assertEquals(0, issue.filesWithCopyrightMissingIssue.size());\n            assertTrue(issue.filesWithCopyrightFormatIssue.containsKey(\"oracle\"));\n\n            // Remove the trailing whitespace\n            Files.write(afile, List.of(\"/*\\n\" +\n                    \" * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.\\n\" +\n                    \" * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\\n\" +\n                    \" */\\n\"));\n            r.add(afile);\n            var second = r.commit(\"2: Modified a.java\", \"duke\", \"duke@openjdk.org\");\n            check = new CopyrightFormatCheck(r);\n            commit = r.lookup(second).orElseThrow();\n            // No issue right now\n            assertFalse(check.check(commit, message(commit), conf(), null).hasNext());\n\n            // Add a Red Hat copyright with a trailing whitespace issue\n            Files.write(afile, List.of(\"/*\\n\" +\n                    \" * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.\\n\" +\n                    \" * Copyright (c) 2024,  Red Hat, Inc.\\n\" +\n                    \" * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\\n\" +\n                    \" */\\n\"));\n            r.add(afile);\n            var third = r.commit(\"3: Modified a.java\", \"duke\", \"duke@openjdk.org\");\n            check = new CopyrightFormatCheck(r);\n            commit = r.lookup(third).orElseThrow();\n            issue = (CopyrightFormatIssue) check.check(commit, message(commit), conf(), null).next();\n            assertEquals(1, issue.filesWithCopyrightFormatIssue.size());\n            assertEquals(0, issue.filesWithCopyrightMissingIssue.size());\n            assertTrue(issue.filesWithCopyrightFormatIssue.containsKey(\"redhat\"));\n\n            // Remove oracle copyright header and fix redhat copyright\n            Files.write(afile, List.of(\"/*\\n\" +\n                    \" * Copyright (c) 2024, Red Hat, Inc.\\n\" +\n                    \" * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\\n\" +\n                    \" */\\n\"));\n            r.add(afile);\n            var fourth = r.commit(\"4: Modified a.java\", \"duke\", \"duke@openjdk.org\");\n            check = new CopyrightFormatCheck(r);\n            commit = r.lookup(fourth).orElseThrow();\n            issue = (CopyrightFormatIssue) check.check(commit, message(commit), conf(), null).next();\n            assertEquals(0, issue.filesWithCopyrightFormatIssue.size());\n            assertEquals(1, issue.filesWithCopyrightMissingIssue.size());\n            assertTrue(issue.filesWithCopyrightMissingIssue.containsKey(\"oracle\"));\n        }\n    }\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/DuplicateIssuesCheckTests.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.test.TestableRepository;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.*;\nimport org.openjdk.skara.test.TemporaryDirectory;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport java.util.*;\nimport java.time.ZonedDateTime;\nimport java.io.IOException;\nimport java.nio.file.*;\nimport static java.nio.file.StandardOpenOption.*;\n\nclass DuplicateIssuesCheckTests {\n    private static JCheckConfiguration conf() {\n        return JCheckConfiguration.parse(List.of(\n            \"[general]\",\n            \"project = test\",\n            \"[checks]\",\n            \"error = duplicate-issues\"\n        ));\n    }\n\n    private static CommitMessage message(Commit c) {\n        return CommitMessageParsers.v1.parse(c);\n    }\n\n    private static List<Issue> toList(Iterator<Issue> i) {\n        var list = new ArrayList<Issue>();\n        while (i.hasNext()) {\n            list.add(i.next());\n        }\n        return list;\n    }\n\n    @Test\n    void noDuplicatedIssuesShouldPass() throws IOException {\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), VCS.GIT);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n            r.add(readme);\n            var first = r.commit(\"1: Added README and .jcheck/conf\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"One more line\"), WRITE, APPEND);\n            r.add(readme);\n            var second = r.commit(\"2: Modified README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"One more line again\"), WRITE, APPEND);\n            r.add(readme);\n            var third = r.commit(\"3: Modified README again\", \"duke\", \"duke@openjdk.org\");\n            var check = new DuplicateIssuesCheck(r);\n\n            var commit = r.lookup(third).orElseThrow();\n            var issues = toList(check.check(commit, message(commit), conf(), null));\n            assertEquals(List.of(), issues);\n        }\n    }\n\n    @Test\n    void duplicateIssuesInMessageShouldFail() throws IOException {\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), VCS.GIT);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n            r.add(readme);\n            var first = r.commit(\"1: Added README and .jcheck/conf\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"One more line\"), WRITE, APPEND);\n            r.add(readme);\n            var second = r.commit(\"2: Modified README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"One more line again\"), WRITE, APPEND);\n            r.add(readme);\n            var third = r.commit(\"3: Modified README again\\n3: Modified README again\", \"duke\", \"duke@openjdk.org\");\n\n            var check = new DuplicateIssuesCheck(r);\n\n            var commit = r.lookup(third).orElseThrow();\n            var issues = toList(check.check(commit, message(commit), conf(), null));\n            assertEquals(2, issues.size());\n            assertTrue(issues.get(0) instanceof DuplicateIssuesIssue);\n            var issue = (DuplicateIssuesIssue) issues.get(0);\n            assertEquals(\"3\", issue.issue().id());\n            assertEquals(List.of(third), issue.hashes());\n        }\n    }\n\n    @Test\n    void duplicateIssuesInPreviousCommitsShouldFail() throws IOException {\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), VCS.GIT);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n            r.add(readme);\n            var first = r.commit(\"1: Added README and .jcheck/conf\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"One more line\"), WRITE, APPEND);\n            r.add(readme);\n            var second = r.commit(\"2: Modified README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"One more line again\"), WRITE, APPEND);\n            r.add(readme);\n            var third = r.commit(\"2: Modified README again\", \"duke\", \"duke@openjdk.org\");\n\n            var check = new DuplicateIssuesCheck(r);\n            var commit = r.lookup(third).orElseThrow();\n            var issues = toList(check.check(commit, message(commit), conf(), null));\n            assertEquals(1, issues.size());\n            assertTrue(issues.get(0) instanceof DuplicateIssuesIssue);\n            var issue = (DuplicateIssuesIssue) issues.get(0);\n            assertEquals(\"2\", issue.issue().id());\n            assertEquals(2, issue.hashes().size());\n            assertTrue(issue.hashes().contains(second));\n            assertTrue(issue.hashes().contains(third));\n        }\n    }\n\n    @Test\n    void duplicatedIssuesInSeparateBranchesShouldPass() throws IOException {\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), VCS.GIT);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n            r.add(readme);\n            var first = r.commit(\"1: Added README and .jcheck/conf\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"One more line\"), WRITE, APPEND);\n            r.add(readme);\n            var second = r.commit(\"2: Modified README\", \"duke\", \"duke@openjdk.org\");\n\n            var myBranch = r.branch(first, \"myBranch\");\n            r.checkout(myBranch);\n\n            Files.write(readme, List.of(\"Another line\"), WRITE, APPEND);\n            r.add(readme);\n            var third = r.commit(\"2: Modified README\", \"duke\", \"duke@openjdk.org\");\n            var check = new DuplicateIssuesCheck(r);\n\n            var commit = r.lookup(third).orElseThrow();\n            var issues = toList(check.check(commit, message(commit), conf(), null));\n            assertEquals(List.of(), issues);\n        }\n    }\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/ExecutableCheckTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport java.nio.file.Path;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.ArrayList;\nimport java.time.ZonedDateTime;\nimport java.io.IOException;\n\nclass ExecutableCheckTests {\n    private static final JCheckConfiguration conf = JCheckConfiguration.parse(List.of(\n        \"[general]\",\n        \"project = test\",\n        \"[checks]\",\n        \"error = executable\"\n    ));\n\n    private static List<Diff> parentDiffs(String filename, String mode) {\n        var hunk = new Hunk(new Range(1, 0), List.of(),\n                            new Range(1, 1), List.of(\"An additional line\"));\n        var patch = new TextualPatch(Path.of(filename), FileType.fromOctal(\"100644\"), Hash.zero(),\n                                     Path.of(filename), FileType.fromOctal(mode), Hash.zero(),\n                                     Status.from('M'), List.of(hunk));\n        var diff = new Diff(Hash.zero(), Hash.zero(), List.of(patch));\n        return List.of(diff);\n    }\n\n\n    private static Commit commit(List<Diff> parentDiffs) {\n        var author = new Author(\"foo\", \"foo@host.org\");\n        var hash = new Hash(\"0123456789012345678901234567890123456789\");\n        var parents = List.of(hash, hash);\n        var message = List.of(\"A commit\");\n        var authored = ZonedDateTime.now();\n        var metadata = new CommitMetadata(hash, parents, author, authored, author, authored, message);\n        return new Commit(metadata, parentDiffs);\n    }\n\n    private List<Issue> toList(Iterator<Issue> i) {\n        var list = new ArrayList<Issue>();\n        while (i.hasNext()) {\n            list.add(i.next());\n        }\n        return list;\n    }\n\n    private static CommitMessage message(Commit c) {\n        return CommitMessageParsers.v1.parse(c);\n    }\n\n    @Test\n    void regularFileShouldPass() throws IOException {\n        var commit = commit(parentDiffs(\"README\", \"100644\"));\n        var message = message(commit);\n        var check = new ExecutableCheck();\n        var issues = toList(check.check(commit, message, conf, null));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void executableFileShouldFail() throws IOException {\n        var commit = commit(parentDiffs(\"README\", \"100755\"));\n        var message = message(commit);\n        var check = new ExecutableCheck();\n        var issues = toList(check.check(commit, message, conf, null));\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof ExecutableIssue);\n        var issue = (ExecutableIssue) issues.get(0);\n        assertEquals(Path.of(\"README\"), issue.path());\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(check, issue.check());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n}\n\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/HgTagCommitCheckTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport java.nio.file.Path;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.ArrayList;\nimport java.time.ZonedDateTime;\nimport java.io.IOException;\n\nclass HgTagCommitCheckTests {\n    private static List<Diff> parentDiffs(String line) {\n        var hunk = new Hunk(new Range(1, 0), List.of(),\n                            new Range(1, 1), List.of(line));\n        var patch = new TextualPatch(Path.of(\".hgtags\"), FileType.fromOctal(\"100644\"), Hash.zero(),\n                                     Path.of(\".hgtags\"), FileType.fromOctal(\"100644\"), Hash.zero(),\n                                     Status.from('M'), List.of(hunk));\n        var diff = new Diff(Hash.zero(), Hash.zero(), List.of(patch));\n        return List.of(diff);\n    }\n\n    private static final JCheckConfiguration conf = JCheckConfiguration.parse(List.of(\n        \"[general]\",\n        \"project = test\",\n        \"[repository]\",\n        \"tags=skara-(?:[1-9](?:[0-9]*)(?:\\\\.[0-9]){0,3})\\\\+(?:[0-9]+)\",\n        \"[checks]\",\n        \"error = hg-tag\"\n    ));\n\n    private static Commit commit(Hash hash, List<String> message, List<Diff> parentDiffs) {\n        var author = new Author(\"Foo Bar\", \"foo@bar.org\");\n        var parents = List.of(new Hash(\"12345789012345789012345678901234567890\"));\n        var authored = ZonedDateTime.now();\n        var metadata = new CommitMetadata(hash, parents, author, authored, author, authored, message);\n        return new Commit(metadata, parentDiffs);\n    }\n\n    private static Commit mergeCommit() {\n        var author = new Author(\"Foo Bar\", \"foo@bar.org\");\n        var parents = List.of(new Hash(\"12345789012345789012345678901234567890\"),\n                              new Hash(\"12345789012345789012345678901234567890\"));\n        var message = List.of(\"Merge\");\n        var authored = ZonedDateTime.now();\n        var metadata = new CommitMetadata(Hash.zero(), parents, author, authored, author, authored, message);\n        return new Commit(metadata, List.of());\n    }\n\n    private static CommitMessage message(Commit c) {\n        return CommitMessageParsers.v1.parse(c);\n    }\n\n    private List<Issue> toList(Iterator<Issue> i) {\n        var list = new ArrayList<Issue>();\n        while (i.hasNext()) {\n            list.add(i.next());\n        }\n        return list;\n    }\n\n    @Test\n    void regularTagShoudlPass() {\n        var targetHash = \"12345789012345789012345678901234567890\";\n        var commitHash = \"1111222233334444555566667777888899990000\";\n        var tag = \"skara-11+22\";\n        var diffs = parentDiffs(targetHash + \" \" + tag);\n        var lines = List.of(\"Added tag \" + tag + \" for changeset \" + targetHash);\n        var commit = commit(new Hash(commitHash), lines, diffs);\n        var check = new HgTagCommitCheck(new Utilities());\n        var issues = toList(check.check(commit, message(commit), conf, null));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void commitThatDoesNotAddTagShouldPass() {\n        var commit = commit(Hash.zero(), List.of(), List.of());\n        var check = new HgTagCommitCheck(new Utilities());\n        var issues = toList(check.check(commit, message(commit), conf, null));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void mergeCommitShouldPass() {\n        var commit = mergeCommit();\n        var check = new HgTagCommitCheck(new Utilities());\n        var issues = toList(check.check(commit, message(commit), conf, null));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void multiLineMessageShouldFail() {\n        var targetHash = \"12345789012345789012345678901234567890\";\n        var commitHash = \"1111222233334444555566667777888899990000\";\n        var tag = \"skara-11+22\";\n        var diffs = parentDiffs(targetHash + \" \" + tag);\n        var lines = List.of(\"Added tag \" + tag + \" for changeset \" + targetHash, \"Another line\");\n        var commit = commit(new Hash(commitHash), lines, diffs);\n        var check = new HgTagCommitCheck(new Utilities());\n        var issues = toList(check.check(commit, message(commit), conf, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof HgTagCommitIssue);\n        var issue = (HgTagCommitIssue) issues.get(0);\n        assertEquals(HgTagCommitIssue.Error.TOO_MANY_LINES, issue.error());\n        assertEquals(commit, issue.commit());\n        assertEquals(check, issue.check());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n\n    @Test\n    void badCommitMessageShouldFail() {\n        var targetHash = \"12345789012345789012345678901234567890\";\n        var commitHash = \"1111222233334444555566667777888899990000\";\n        var tag = \"skara-11+22\";\n        var diffs = parentDiffs(targetHash + \" \" + tag);\n        var lines = List.of(\"I want tag \" + tag + \" for commit \" + targetHash);\n        var commit = commit(new Hash(commitHash), lines, diffs);\n        var check = new HgTagCommitCheck(new Utilities());\n        var issues = toList(check.check(commit, message(commit), conf, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof HgTagCommitIssue);\n        var issue = (HgTagCommitIssue) issues.get(0);\n        assertEquals(HgTagCommitIssue.Error.BAD_FORMAT, issue.error());\n        assertEquals(commit, issue.commit());\n        assertEquals(check, issue.check());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n\n    @Test\n    void multiplePatchesShouldFail() {\n        var targetHash = \"12345789012345789012345678901234567890\";\n        var tag = \"skara-11+22\";\n\n        var hunk1 = new Hunk(new Range(1, 0), List.of(),\n                            new Range(1, 1), List.of(targetHash + \" \" + tag));\n        var patch1 = new TextualPatch(Path.of(\".hgtags\"), FileType.fromOctal(\"100644\"), Hash.zero(),\n                               Path.of(\".hgtags\"), FileType.fromOctal(\"100644\"), Hash.zero(),\n                               Status.from('M'), List.of(hunk1));\n        var hunk2 = new Hunk(new Range(1, 0), List.of(),\n                            new Range(1, 1), List.of(\"An additional line\"));\n        var patch2 = new TextualPatch(Path.of(\"README\"), FileType.fromOctal(\"100644\"), Hash.zero(),\n                                      Path.of(\"README\"), FileType.fromOctal(\"100644\"), Hash.zero(),\n                                      Status.from('M'), List.of(hunk2));\n        var diff = new Diff(Hash.zero(), Hash.zero(), List.of(patch1, patch2));\n        var diffs = List.of(diff);\n\n        var commitHash = \"1111222233334444555566667777888899990000\";\n        var lines = List.of(\"Added tag \" + tag + \" for changeset \" + targetHash);\n        var commit = commit(new Hash(commitHash), lines, diffs);\n\n        var check = new HgTagCommitCheck(new Utilities());\n        var issues = toList(check.check(commit, message(commit), conf, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof HgTagCommitIssue);\n        var issue = (HgTagCommitIssue) issues.get(0);\n            assertEquals(HgTagCommitIssue.Error.TOO_MANY_CHANGES, issue.error());\n        assertEquals(commit, issue.commit());\n        assertEquals(check, issue.check());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n\n    @Test\n    void multipleHunksShouldFail() {\n        var targetHash = \"12345789012345789012345678901234567890\";\n        var tag = \"skara-11+22\";\n\n        var hunk1 = new Hunk(new Range(1, 0), List.of(),\n                            new Range(1, 1), List.of(targetHash + \" \" + tag));\n        var hunk2 = new Hunk(new Range(1, 0), List.of(),\n                            new Range(2, 1), List.of(targetHash + \" \" + \"skara-11+23\"));\n        var patch = new TextualPatch(Path.of(\".hgtags\"), FileType.fromOctal(\"100644\"), Hash.zero(),\n                                     Path.of(\".hgtags\"), FileType.fromOctal(\"100644\"), Hash.zero(),\n                                     Status.from('M'), List.of(hunk1, hunk2));\n        var diff = new Diff(Hash.zero(), Hash.zero(), List.of(patch));\n        var diffs = List.of(diff);\n\n        var commitHash = \"1111222233334444555566667777888899990000\";\n        var lines = List.of(\"Added tag \" + tag + \" for changeset \" + targetHash);\n        var commit = commit(new Hash(commitHash), lines, diffs);\n\n        var check = new HgTagCommitCheck(new Utilities());\n        var issues = toList(check.check(commit, message(commit), conf, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof HgTagCommitIssue);\n        var issue = (HgTagCommitIssue) issues.get(0);\n            assertEquals(HgTagCommitIssue.Error.TOO_MANY_CHANGES, issue.error());\n        assertEquals(commit, issue.commit());\n        assertEquals(check, issue.check());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n\n    @Test\n    void differentTagInMessageAndHunkShouldFail() {\n        var targetHash = \"12345789012345789012345678901234567890\";\n        var commitHash = \"1111222233334444555566667777888899990000\";\n        var tag = \"skara-11+22\";\n        var diffs = parentDiffs(targetHash + \" \" + tag);\n        var lines = List.of(\"Added tag skara-11+23 for changeset \" + targetHash);\n        var commit = commit(new Hash(commitHash), lines, diffs);\n        var check = new HgTagCommitCheck(new Utilities());\n        var issues = toList(check.check(commit, message(commit), conf, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof HgTagCommitIssue);\n        var issue = (HgTagCommitIssue) issues.get(0);\n            assertEquals(HgTagCommitIssue.Error.TAG_DIFFERS, issue.error());\n        assertEquals(commit, issue.commit());\n        assertEquals(check, issue.check());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/IssuesCheckTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.Author;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.CommitMetadata;\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.ArrayList;\nimport java.time.ZonedDateTime;\nimport java.io.IOException;\n\nclass IssuesCheckTests {\n    private final Utilities utils = new Utilities();\n\n    // Default issue pattern: optional prefix followed by 1 or more digits\n    private static final List<String> CONFIGURATION = List.of(\n        \"[general]\",\n        \"project = test\",\n        \"[checks]\",\n        \"error = issues\"\n    );\n\n    // Issue pattern with a required prefix\n    private static final List<String> CONFIGURATION2 = List.of(\n        \"[general]\",\n        \"project = test\",\n        \"[checks]\",\n        \"error = issues\",\n        \"[checks \\\"issues\\\"]\",\n        \"pattern = ^(PROJ-[1-9][0-9]+): (\\\\S.*)$\"\n    );\n\n    // Default issue pattern for legacy conf: 7 digit starting with [124-8]\n    private static final List<String> CONFIGURATION3 = List.of(\n        \"project=jdk\",\n        \"bugids=dup\"\n    );\n\n    private static JCheckConfiguration conf() {\n        return JCheckConfiguration.parse(CONFIGURATION);\n    }\n\n\n    private static JCheckConfiguration conf2() {\n        return JCheckConfiguration.parse(CONFIGURATION2);\n    }\n\n    private static JCheckConfiguration conf3() {\n        return JCheckConfiguration.parse(CONFIGURATION3);\n    }\n    private static Commit commit(List<String> message) {\n        var author = new Author(\"foo\", \"foo@host.org\");\n        var hash = new Hash(\"0123456789012345678901234567890123456789\");\n        var parents = List.of(hash);\n        var authored = ZonedDateTime.now();\n        var metadata = new CommitMetadata(hash, parents, author, authored, author, authored, message);\n        return new Commit(metadata, List.of());\n    }\n\n    private static CommitMessage message(Commit c) {\n        return CommitMessageParsers.v1.parse(c);\n    }\n\n    private List<Issue> toList(Iterator<Issue> i) {\n        var list = new ArrayList<Issue>();\n        while (i.hasNext()) {\n            list.add(i.next());\n        }\n        return list;\n    }\n\n    @Test\n    void titleOnlyMessageShouldFail() {\n        var commit = commit(List.of(\"Bugfix\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof IssuesIssue);\n        var issue = (IssuesIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check.getClass(), issue.check().getClass());\n    }\n\n    @Test\n    void singleIssueReferenceShouldPass() {\n        var commit = commit(List.of(\"1234570: A bug\"));\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(), null));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void multipleIssueReferencesShouldPass() {\n        var commit = commit(List.of(\"1234570: A bug\", \"1234567: Another bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf(), null));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void issueWithLeadingZeroShouldPass() {\n        var commit = commit(List.of(\"0123456: A bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf(), null));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void issueWithTooFewDigitsShouldPass() {\n        var commit = commit(List.of(\"123456: A bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf(), null));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void issueWithTooManyDigitsShouldPass() {\n        var commit = commit(List.of(\"12345678: A bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf(), null));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void issueWithPrefixShouldPass() {\n        var commit = commit(List.of(\"JDK-7654321: A bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf(), null));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void issueWithPrefixConf2ShouldPass() {\n        var commit = commit(List.of(\"PROJ-1234567: A bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf2(), null));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void issueWithoutPrefixConf2ShouldFail() {\n        var commit = commit(List.of(\"1234567: A bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf2(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof IssuesIssue);\n        var issue = (IssuesIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check.getClass(), issue.check().getClass());\n    }\n\n    @Test\n    void issueWithBadPrefixConf2ShouldFail() {\n        var commit = commit(List.of(\"JDK-1234567: A bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf2(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof IssuesIssue);\n        var issue = (IssuesIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check.getClass(), issue.check().getClass());\n    }\n\n    @Test\n    void singleIssueReferenceConf3ShouldPass() {\n        var commit = commit(List.of(\"1234570: A bug\"));\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf3(), null));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void multipleIssueReferencesConf3ShouldPass() {\n        var commit = commit(List.of(\"1234570: A bug\", \"1234567: Another bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf3(), null));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void issueWithLeadingZeroConf3ShouldFail() {\n        var commit = commit(List.of(\"0123456: A bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf3(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof IssuesIssue);\n        var issue = (IssuesIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check.getClass(), issue.check().getClass());\n    }\n\n    @Test\n    void issueWithLeadingNineConf3ShouldFail() {\n        var commit = commit(List.of(\"9876543: A bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf3(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof IssuesIssue);\n        var issue = (IssuesIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check.getClass(), issue.check().getClass());\n    }\n\n    @Test\n    void issueWithTooFewDigitsConf3ShouldFail() {\n        var commit = commit(List.of(\"123456: A bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf3(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof IssuesIssue);\n        var issue = (IssuesIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check.getClass(), issue.check().getClass());\n    }\n\n    @Test\n    void issueWithTooManyDigitsConf3ShouldFail() {\n        var commit = commit(List.of(\"12345678: A bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf3(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof IssuesIssue);\n        var issue = (IssuesIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check.getClass(), issue.check().getClass());\n    }\n\n    @Test\n    void issueWithPrefixConf3ShouldFail() {\n        var commit = commit(List.of(\"JDK-7654321: A bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf3(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof IssuesIssue);\n        var issue = (IssuesIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check.getClass(), issue.check().getClass());\n    }\n\n    @Test\n    void multipleIssueReferencesFirstBadConf3ShouldFail() {\n        var commit = commit(List.of(\"12345: A bug\", \"1234567: Another bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf3(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof IssuesIssue);\n        var issue = (IssuesIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check.getClass(), issue.check().getClass());\n    }\n\n    @Test\n    void multipleIssueReferencesLastBadConf3ShouldFail() {\n        var commit = commit(List.of(\"1234567: A bug\", \"012: Another bug\"));\n        var message = message(commit);\n        var check = new IssuesCheck(utils);\n        var issues = toList(check.check(commit, message, conf3(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof IssuesIssue);\n        var issue = (IssuesIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check.getClass(), issue.check().getClass());\n    }\n\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/JCheckTests.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestableRepository;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.time.Instant;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.junit.jupiter.api.Assumptions.assumeFalse;\n\nclass JCheckTests {\n    static class CheckableRepository {\n        public static Repository create(Path path, VCS vcs) throws IOException {\n            var repo = TestableRepository.init(path, vcs);\n\n            Files.createDirectories(path.resolve(\".jcheck\"));\n            var checkConf = path.resolve(\".jcheck/conf\");\n            try (var output = new FileWriter(checkConf.toFile())) {\n                output.append(\"[general]\\n\");\n                output.append(\"project=test\\n\");\n                output.append(\"\\n\");\n                output.append(\"[checks]\\n\");\n                output.append(\"error=reviewers,whitespace\\n\");\n                output.append(\"\\n\");\n                output.append(\"[census]\\n\");\n                output.append(\"version=0\\n\");\n                output.append(\"domain=openjdk.org\\n\");\n                output.append(\"\\n\");\n                output.append(\"[checks \\\"whitespace\\\"]\\n\");\n                output.append(\"suffixes=.txt\\n\");\n                output.append(\"\\n\");\n                output.append(\"[checks \\\"reviewers\\\"]\\n\");\n                output.append(\"minimum=1\\n\");\n            }\n            repo.add(checkConf);\n\n            repo.commit(\"Initial commit\\n\\nReviewed-by: user2\", \"user3\", \"user3@openjdk.org\");\n\n            return repo;\n        }\n    }\n\n    static class CensusCreator {\n        static void populateCensusDirectory(Path censusDir) throws IOException {\n            var contributorsFile = censusDir.resolve(\"contributors.xml\");\n            var contributorsContent = List.of(\n                    \"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\",\n                    \"<contributors>\",\n                    \"    <contributor username=\\\"user1\\\" full-name=\\\"User Number 1\\\" />\",\n                    \"    <contributor username=\\\"user2\\\" full-name=\\\"User Number 2\\\" />\",\n                    \"    <contributor username=\\\"user3\\\" full-name=\\\"User Number 3\\\" />\",\n                    \"    <contributor username=\\\"user4\\\" full-name=\\\"User Number 4\\\" />\",\n                    \"</contributors>\");\n            Files.write(contributorsFile, contributorsContent);\n\n            var groupsDir = censusDir.resolve(\"groups\");\n            Files.createDirectories(groupsDir);\n\n            var testGroupFile = groupsDir.resolve(\"test.xml\");\n            var testGroupContent = List.of(\n                    \"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\",\n                    \"<group name=\\\"test\\\" full-name=\\\"TEST\\\">\",\n                    \"    <lead username=\\\"user_4\\\" />\",\n                    \"    <member username=\\\"user1\\\" since=\\\"0\\\" />\",\n                    \"    <member username=\\\"user2\\\" since=\\\"0\\\" />\",\n                    \"</group>\");\n            Files.write(testGroupFile, testGroupContent);\n\n            var projectDir = censusDir.resolve(\"projects\");\n            Files.createDirectories(projectDir);\n\n            var testProjectFile = projectDir.resolve(\"test.xml\");\n            var testProjectContent = List.of(\n                    \"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\",\n                    \"<project name=\\\"test\\\" full-name=\\\"TEST\\\" sponsor=\\\"test\\\">\",\n                    \"    <lead username=\\\"user1\\\" since=\\\"0\\\" />\",\n                    \"    <reviewer username=\\\"user2\\\" since=\\\"0\\\" />\",\n                    \"    <committer username=\\\"user3\\\" since=\\\"0\\\" />\",\n                    \"    <author username=\\\"user4\\\" since=\\\"0\\\" />\",\n                    \"</project>\");\n            Files.write(testProjectFile, testProjectContent);\n\n            var namespacesDir = censusDir.resolve(\"namespaces\");\n            Files.createDirectories(namespacesDir);\n\n            var namespaceFile = namespacesDir.resolve(\"github.xml\");\n            var namespaceContent = List.of(\n                    \"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\",\n                    \"<namespace name=\\\"github.com\\\">\",\n                    \"    <user id=\\\"1234567\\\" census=\\\"user1\\\" />\",\n                    \"    <user id=\\\"2345678\\\" census=\\\"user2\\\" />\",\n                    \"</namespace>\");\n            Files.write(namespaceFile, namespaceContent);\n\n            var versionFile = censusDir.resolve(\"version.xml\");\n            var versionContent = List.of(\n                    \"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\",\n                    \"<version format=\\\"1\\\" timestamp=\\\"\" + Instant.now().toString() + \"\\\" />\");\n            Files.write(versionFile, versionContent);\n        }\n    }\n\n    class TestVisitor implements IssueVisitor {\n        private final Set<Issue> issues = new HashSet<>();\n\n        @Override\n        public void visit(TagIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(BranchIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(DuplicateIssuesIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(SelfReviewIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(TooFewReviewersIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(MergeMessageIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(HgTagCommitIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(CommitterIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(WhitespaceIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(MessageIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(MessageWhitespaceIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(IssuesIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(InvalidReviewersIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(ExecutableIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(SymlinkIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(AuthorNameIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(AuthorEmailIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(CommitterNameIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(CommitterEmailIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(BinaryIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(ProblemListsIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(IssuesTitleIssue e) {\n            issues.add(e);\n        }\n\n        @Override\n        public void visit(CopyrightFormatIssue e) {\n            issues.add(e);\n        }\n\n        Set<Issue> issues() {\n            return issues;\n        }\n\n        Set<String> issueNames() {\n            return issues.stream()\n                         .map(issue -> issue.getClass().getName())\n                         .collect(Collectors.toSet());\n        }\n    }\n\n    private static boolean hgAvailable = true;\n\n    @BeforeAll\n    static void checkHgAvailability() {\n        try {\n            var pb = new ProcessBuilder(\"hg\", \"--version\");\n            pb.redirectErrorStream(true);\n            var process = pb.start();\n            process.waitFor();\n            hgAvailable = (process.exitValue() == 0);\n        } catch (Exception e) {\n            hgAvailable = false;\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void checksForCommit(VCS vcs) throws Exception {\n        try (var dir = new TemporaryDirectory()) {\n            assumeFalse(vcs == VCS.HG && !hgAvailable);\n            var repoPath = dir.path().resolve(\"repo\");\n            var repo = CheckableRepository.create(repoPath, vcs);\n\n            var readme = repoPath.resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n            repo.add(readme);\n            var first = repo.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            var checks = JCheck.checksFor(repo, first);\n            var checkNames = checks.stream()\n                                   .map(Check::name)\n                                   .collect(Collectors.toSet());\n            assertEquals(Set.of(\"whitespace\", \"reviewers\"), checkNames);\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void checkRemoval(VCS vcs) throws Exception {\n        try (var dir = new TemporaryDirectory()) {\n            assumeFalse(vcs == VCS.HG && !hgAvailable);\n            var repoPath = dir.path().resolve(\"repo\");\n            var repo = CheckableRepository.create(repoPath, vcs);\n\n            var file = repoPath.resolve(\"file.txt\");\n            Files.write(file, List.of(\"Hello, file!\"));\n            repo.add(file);\n            var first = repo.commit(\"Add file\", \"duke\", \"duke@openjdk.org\");\n\n            Files.delete(file);\n            repo.remove(file);\n            var second = repo.commit(\"Remove file\", \"duke\", \"duke@openjdk.org\");\n\n            var censusPath = dir.path().resolve(\"census\");\n            Files.createDirectories(censusPath);\n            CensusCreator.populateCensusDirectory(censusPath);\n            var census = Census.parse(censusPath);\n\n            var visitor = new TestVisitor();\n            try (var issues = JCheck.check(repo, census, CommitMessageParsers.v1, first.hex() + \"..\" + second.hex(), null)) {\n                for (var issue : issues) {\n                    issue.accept(visitor);\n                }\n            }\n            assertEquals(Set.of(\"org.openjdk.skara.jcheck.TooFewReviewersIssue\"), visitor.issueNames());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void checkOverridingConfiguration(VCS vcs) throws Exception {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var repoPath = dir.path().resolve(\"repo\");\n            var repo = CheckableRepository.create(repoPath, vcs);\n\n            var initialCommit = repo.commits().asList().get(0);\n\n            var jcheckConf = repoPath.resolve(\".jcheck\").resolve(\"conf\");\n            assertTrue(Files.exists(jcheckConf));\n            Files.writeString(jcheckConf, \"[checks \\\"reviewers\\\"]\\nminimum = 0\\n\",\n                              StandardOpenOption.WRITE, StandardOpenOption.APPEND);\n            repo.add(jcheckConf);\n            var secondCommit = repo.commit(\"Do not require reviews\", \"user3\", \"user3@openjdk.org\");\n\n            var censusPath = dir.path().resolve(\"census\");\n            Files.createDirectories(censusPath);\n            CensusCreator.populateCensusDirectory(censusPath);\n            var census = Census.parse(censusPath);\n\n            // Check the last commit without reviewers, should pass since .jcheck/conf was updated\n            var range = initialCommit.hash().hex() + \"..\" + secondCommit.hex();\n            var visitor = new TestVisitor();\n            try (var issues = JCheck.check(repo, census, CommitMessageParsers.v1, range, null)) {\n                for (var issue : issues) {\n                    issue.accept(visitor);\n                }\n            }\n            assertEquals(Set.of(), visitor.issues());\n\n            // Check the last commit without reviewers with the initial .jcheck/conf. Should fail\n            // due to missing reviewers.\n            var conf = JCheck.parseConfiguration(repo, initialCommit.hash(), List.of()).orElseThrow();\n            try (var issues = JCheck.check(repo, census, CommitMessageParsers.v1, secondCommit, conf)) {\n                for (var issue : issues) {\n                    issue.accept(visitor);\n                }\n            }\n            assertEquals(Set.of(\"org.openjdk.skara.jcheck.TooFewReviewersIssue\"), visitor.issueNames());\n        }\n    }\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/MergeMessageCheckTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.*;\n\nimport java.io.IOException;\nimport java.time.ZonedDateTime;\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass MergeMessageCheckTests {\n    private static final List<String> CONFIGURATION = List.of(\n        \"[general]\",\n        \"project = test\",\n        \"[checks]\",\n        \"error = merge\",\n        \"[checks \\\"merge\\\"]\",\n        \"message = Merge\"\n    );\n\n    private static JCheckConfiguration conf() throws IOException {\n        return JCheckConfiguration.parse(CONFIGURATION);\n    }\n\n    private static Commit commit(List<String> message) {\n        var author = new Author(\"foo\", \"foo@host.org\");\n        var hash = new Hash(\"0123456789012345678901234567890123456789\");\n        var parents = List.of(hash, hash);\n        var authored = ZonedDateTime.now();\n        var metadata = new CommitMetadata(hash, parents, author, authored, author, authored, message);\n        return new Commit(metadata, List.of());\n    }\n\n    private List<Issue> toList(Iterator<Issue> i) {\n        var list = new ArrayList<Issue>();\n        while (i.hasNext()) {\n            list.add(i.next());\n        }\n        return list;\n    }\n\n    private static CommitMessage message(Commit c) {\n        return CommitMessageParsers.v1.parse(c);\n    }\n\n    @Test\n    void correctMessageShouldPass() throws IOException {\n        var commit = commit(List.of(\"Merge\"));\n        var message = message(commit);\n        var check = new MergeMessageCheck();\n        var issues = toList(check.check(commit, message, conf(), null));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void incorrectMessageShouldFail() throws IOException {\n        var commit = commit(List.of(\"Work\"));\n        var message = message(commit);\n        var check = new MergeMessageCheck();\n        var issues = toList(check.check(commit, message, conf(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof MergeMessageIssue);\n    }\n\n    @Test\n    void multiLineMessageShouldWork() throws IOException {\n        var commit = commit(List.of(\"Merge\", \"\", \"This is a summary\"));\n        var message = message(commit);\n        var check = new MergeMessageCheck();\n        var issues = toList(check.check(commit, message, conf(), null));\n\n        assertEquals(List.of(), issues);\n    }\n\n    @Test\n    void usingRegexShouldWork() throws IOException {\n        var commit = commit(List.of(\"Merge 'feature' into 'master'\"));\n        var message = message(commit);\n        var check = new MergeMessageCheck();\n        var conf = new ArrayList<>(CONFIGURATION);\n        conf.set(conf.size() - 1, \"message = Merge \\\\'[a-z]+\\\\' into \\\\'[a-z]+\\\\'\");\n        var issues = toList(check.check(commit, message, JCheckConfiguration.parse(conf), null));\n\n        assertEquals(List.of(), issues);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/MessageCheckTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.Author;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.CommitMetadata;\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.ArrayList;\nimport java.time.ZonedDateTime;\nimport java.io.IOException;\n\nclass MessageCheckTests {\n    private final Utilities utils = new Utilities();\n\n    private static final List<String> CONFIGURATION = List.of(\n        \"[general]\",\n        \"project = test\",\n        \"[checks]\",\n        \"error = message\"\n    );\n\n    private static JCheckConfiguration conf() {\n        return JCheckConfiguration.parse(CONFIGURATION);\n    }\n\n    private static Commit commit(List<String> message) {\n        var author = new Author(\"foo\", \"foo@host.org\");\n        var hash = new Hash(\"0123456789012345678901234567890123456789\");\n        var parents = List.of(hash);\n        var authored = ZonedDateTime.now();\n        var metadata = new CommitMetadata(hash, parents, author, authored, author, authored, message);\n        return new Commit(metadata, List.of());\n    }\n\n    private static CommitMessage message(Commit c) {\n        return CommitMessageParsers.v1.parse(c);\n    }\n\n    private List<Issue> toList(Iterator<Issue> i) {\n        var list = new ArrayList<Issue>();\n        while (i.hasNext()) {\n            list.add(i.next());\n        }\n        return list;\n    }\n\n    @Test\n    void titleOnlyMessageShouldPass() {\n        var commit = commit(List.of(\"Bugfix\"));\n        var check = new MessageCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(), null));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void emptyMessageShouldFail() {\n        var commit = commit(new ArrayList<String>());\n        var message = message(commit);\n        var check = new MessageCheck(utils);\n        var issues = toList(check.check(commit, message, conf(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof MessageIssue);\n        var issue = (MessageIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(MessageCheck.class, issue.check().getClass());\n    }\n\n    @Test\n    void additionalLinesShouldFail() {\n        var commit = commit(List.of(\"Bugfix\", \"Additional\"));\n        var message = message(commit);\n        var check = new MessageCheck(utils);\n        var issues = toList(check.check(commit, message, conf(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof MessageIssue);\n        var issue = (MessageIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(MessageCheck.class, issue.check().getClass());\n    }\n\n    @Test\n    void tabInCommitMessageShouldFail() {\n        var commit = commit(List.of(\"\\tBugfix\"));\n        var message = message(commit);\n        var check = new MessageCheck(utils);\n        var issues = toList(check.check(commit, message, conf(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof MessageWhitespaceIssue);\n        var issue = (MessageWhitespaceIssue) issues.get(0);\n        assertEquals(MessageWhitespaceIssue.Whitespace.TAB, issue.kind());\n        assertEquals(1, issue.line());\n    }\n\n    @Test\n    void crInCommitMessageShouldFail() {\n        var commit = commit(List.of(\"Bugfix\\r\"));\n        var message = message(commit);\n        var check = new MessageCheck(utils);\n        var issues = toList(check.check(commit, message, conf(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof MessageWhitespaceIssue);\n        var issue = (MessageWhitespaceIssue) issues.get(0);\n        assertEquals(MessageWhitespaceIssue.Whitespace.CR, issue.kind());\n        assertEquals(1, issue.line());\n    }\n\n    @Test\n    void trailingWhitespaceInMessageShouldFail() {\n        var commit = commit(List.of(\"Bugfix \"));\n        var message = message(commit);\n        var check = new MessageCheck(utils);\n        var issues = toList(check.check(commit, message, conf(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof MessageWhitespaceIssue);\n        var issue = (MessageWhitespaceIssue) issues.get(0);\n        assertEquals(MessageWhitespaceIssue.Whitespace.TRAILING, issue.kind());\n        assertEquals(1, issue.line());\n    }\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/ProblemListsCheckTests.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.time.ZonedDateTime;\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass ProblemListsCheckTests {\n\n    // Default dirs and pattern\n    private static final List<String> CONFIGURATION = List.of(\n            \"[general]\",\n            \"project = test\",\n            \"[checks]\",\n            \"error = problemlists\"\n    );\n\n    // Default dirs and custom pattern\n    private static final List<String> CONFIGURATION2 = List.of(\n            \"[general]\",\n            \"project = test\",\n            \"[checks]\",\n            \"error = problemlists\",\n            \"[checks \\\"problemlists\\\"]\",\n            \"pattern = ^ProjProblemList.txt$\"\n    );\n\n    // custom dirs and default pattern\n    private static final List<String> CONFIGURATION3 = List.of(\n            \"[general]\",\n            \"project = test\",\n            \"[checks]\",\n            \"error = problemlists\",\n            \"[checks \\\"problemlists\\\"]\",\n            \"dirs = test1|test2\"\n    );\n\n    // custom dirs and custom pattern\n    private static final List<String> CONFIGURATION4 = List.of(\n            \"[general]\",\n            \"project = test\",\n            \"[checks]\",\n            \"error = problemlists\",\n            \"[checks \\\"problemlists\\\"]\",\n            \"dirs = test1|test2\",\n            \"pattern = ^ProjProblemList.txt$\"\n    );\n\n    private static final JCheckConfiguration conf = JCheckConfiguration.parse(CONFIGURATION);\n    private static final JCheckConfiguration conf2 = JCheckConfiguration.parse(CONFIGURATION2);\n    private static final JCheckConfiguration conf3 = JCheckConfiguration.parse(CONFIGURATION3);\n    private static final JCheckConfiguration conf4 = JCheckConfiguration.parse(CONFIGURATION4);\n\n    private static final ReadOnlyRepository REPOSITORY = new TestRepository() {\n        @Override\n        // always has test*/ProblemList.txt and test*/ProjProblemList.txt\n        // for h == 1XXXX has test*/ProblemList1.txt\n        // for h == 2XXXX has test*/ProblemList2.txt\n        public List<FileEntry> files(Hash h, List<Path> paths) throws IOException {\n            List<FileEntry> result = new ArrayList<>();\n            for (var path : paths) {\n                if (path.equals(Path.of(\"test\"))) {\n                    result.addAll(filesAt(\"test\", h));\n                } else if (path.equals(Path.of(\"test1\"))) {\n                    result.addAll(filesAt(\"test1\", h));\n                } else if (path.equals(Path.of(\"test2\"))) {\n                    result.addAll(filesAt(\"test2\", h));\n                } else {\n                    result.addAll(super.files(h, paths));\n                }\n            }\n            return result;\n        }\n\n        private List<? extends FileEntry> filesAt(String dir, Hash h) {\n            var fileType = FileType.fromOctal(\"100644\");\n            switch (h.hex().charAt(0)) {\n                case '1':\n                    return List.of(new FileEntry(h, fileType, h, Path.of(dir + \"/ProblemList.txt\")),\n                            new FileEntry(h, fileType, h, Path.of(dir + \"/ProblemList1.txt\")),\n                            new FileEntry(h, fileType, h, Path.of(dir + \"/ProjProblemList.txt\")));\n                case '2':\n                    return List.of(new FileEntry(h, fileType, h, Path.of(dir + \"/ProblemList.txt\")),\n                            new FileEntry(h, fileType, h, Path.of(dir + \"/ProblemList2.txt\")),\n                            new FileEntry(h, fileType, h, Path.of(dir + \"/ProjProblemList.txt\")));\n                default:\n                    return List.of(new FileEntry(h, fileType, h, Path.of(dir + \"/ProblemList.txt\")),\n                            new FileEntry(h, fileType, h, Path.of(dir + \"/ProjProblemList.txt\")));\n            }\n        }\n\n        @Override\n        // ProblemList*.txt always contain tests problem listed because of bugs 2 and 3 and unless h[0] == 1 b/c of 1\n        // ProjProblemList.txt always contain tests problem listed because of PROJ-2,PROJ-3 and PROJ1-1\n        // and unless h[0] == 1 b/c of PROJ-1\n        public Optional<List<String>> lines(Path p, Hash h) throws IOException {\n            if (p.getParent().toString().startsWith(\"test\")) {\n                List<String> result;\n                var filename = p.getFileName().toString();\n                if (filename.startsWith(\"ProblemList\") && filename.endsWith(\".txt\")) {\n                    if (h.hex().charAt(0) == '1') {\n                        result = List.of(\"test1 2\", \"test3 2,3\", \"# test 1,2,3\");\n                    } else {\n                        result = List.of(\"test1 1\", \"test1 2\", \"test3 2,3\", \"# test 1,2,3\");\n                    }\n                } else if (filename.equals(\"ProjProblemList.txt\")) {\n                    if (h.hex().charAt(0) == '1') {\n                        result = List.of(\"test1 PROJ-2\", \"test3 PROJ-2,PROJ-3,PROJ1-1\", \"# test PROJ-1,PROJ-2,PROJ-3\");\n                    } else {\n                        result = List.of(\"test1 PROJ-1\", \"test1 PROJ-2\", \"test3 PROJ-2,PROJ-3,PROJ1-1\", \"# test PROJ-1,PROJ-2,PROJ-3\");\n                    }\n                } else {\n                    return super.lines(p, h);\n                }\n                return Optional.of(result);\n            }\n            return super.lines(p, h);\n        }\n    };\n\n    private static Commit commit(int id, String... message) {\n        var author = new Author(\"foo\", \"foo@host.org\");\n        var hash = new Hash((\"\" + id).repeat(40));\n        var parents = List.of(Hash.zero());\n        var authored = ZonedDateTime.now();\n        var metadata = new CommitMetadata(hash, parents, author, authored, author, authored, List.of(message));\n        return new Commit(metadata, List.of());\n    }\n\n    private static CommitMessage message(Commit c) {\n        return CommitMessageParsers.v1.parse(c);\n    }\n\n    private List<Issue> toList(Iterator<Issue> i) {\n        var list = new ArrayList<Issue>();\n        while (i.hasNext()) {\n            list.add(i.next());\n        }\n        return list;\n    }\n\n    @Test\n    void titleOnlyMessageShouldBypass() {\n        var commit = commit(0, \"Bugfix\");\n        var message = message(commit);\n        var check = new ProblemListsCheck(REPOSITORY);\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void singleNeverBeenProblemListed() {\n        var commit = commit(0, \"4: Bugfix\");\n        var message = message(commit);\n        var check = new ProblemListsCheck(REPOSITORY);\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void singlePrefixedNeverBeenProblemListed() {\n        var commit = commit(0, \"PROJ-1: Bugfix\");\n        var message = message(commit);\n        var check = new ProblemListsCheck(REPOSITORY);\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void multipleHaveNeverBeenProblemListed() {\n        var commit = commit(0, \"4: Bugfix\", \"5: Bugfix2\");\n        var message = message(commit);\n        var check = new ProblemListsCheck(REPOSITORY);\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void singleAlwaysProblemListed() {\n        var commit = commit(0, \"3: Bugfix\");\n        var message = message(commit);\n        var check = new ProblemListsCheck(REPOSITORY);\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof ProblemListsIssue);\n        var issue = (ProblemListsIssue) issues.get(0);\n        assertEquals(\"3\", issue.issue());\n        assertEquals(Set.of(Path.of(\"test/ProblemList.txt\")), issue.files());\n    }\n\n    @Test\n    void singleUnproblemListed() {\n        var commit = commit(1, \"1: Bugfix\");\n        var message = message(commit);\n        var check = new ProblemListsCheck(REPOSITORY);\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void singleAlwaysProblemListedInTwoLists() {\n        var check = new ProblemListsCheck(REPOSITORY);\n\n        {\n            var commit = commit(1, \"2: Bugfix\");\n            var message = message(commit);\n            var issues = toList(check.check(commit, message, conf, null));\n\n            assertEquals(1, issues.size());\n            assertTrue(issues.get(0) instanceof ProblemListsIssue);\n            var issue = (ProblemListsIssue) issues.get(0);\n            assertEquals(\"2\", issue.issue());\n            assertEquals(Set.of(Path.of(\"test/ProblemList.txt\"),\n                    Path.of(\"test/ProblemList1.txt\")), issue.files());\n        }\n\n        {\n            var commit = commit(2, \"2: Bugfix\");\n            var message = message(commit);\n            var issues = toList(check.check(commit, message, conf, null));\n\n            assertEquals(1, issues.size());\n            assertTrue(issues.get(0) instanceof ProblemListsIssue);\n            var issue = (ProblemListsIssue) issues.get(0);\n            assertEquals(\"2\", issue.issue());\n            assertEquals(Set.of(Path.of(\"test/ProblemList.txt\"),\n                    Path.of(\"test/ProblemList2.txt\")), issue.files());\n        }\n    }\n\n    @Test\n    void multipleAlwaysProblemListed() {\n        var commit = commit(0, \"2: Bugfix\", \"3: Bugfix2\");\n        var message = message(commit);\n        var check = new ProblemListsCheck(REPOSITORY);\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(2, issues.size());\n        // assume that issues are in the same order as messages\n        assertTrue(issues.get(0) instanceof ProblemListsIssue);\n        var issue = (ProblemListsIssue) issues.get(0);\n        assertEquals(\"2\", issue.issue());\n        assertEquals(Set.of(Path.of(\"test/ProblemList.txt\")), issue.files());\n\n        issue = (ProblemListsIssue) issues.get(1);\n        assertEquals(\"3\", issue.issue());\n        assertEquals(Set.of(Path.of(\"test/ProblemList.txt\")), issue.files());\n    }\n\n    @Test\n    void multipleYetOnlyOneProblemListed() {\n        var commit = commit(0, \"4: Bugfix\", \"3: Bugfix2\");\n        var message = message(commit);\n        var check = new ProblemListsCheck(REPOSITORY);\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(1, issues.size());\n        // assume that issues are in the same order as messages\n        assertTrue(issues.get(0) instanceof ProblemListsIssue);\n        var issue = (ProblemListsIssue) issues.get(0);\n        assertEquals(\"3\", issue.issue());\n        assertEquals(Set.of(Path.of(\"test/ProblemList.txt\")), issue.files());\n    }\n\n    @Test\n    void singlePrefixedNeverBeenProblemListedConf2() {\n        var commit = commit(0, \"PROJ-4: Bugfix\");\n        var message = message(commit);\n        var check = new ProblemListsCheck(REPOSITORY);\n        var issues = toList(check.check(commit, message, conf2, null));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void singleNeverBeenProblemListedConf2() {\n        var commit = commit(0, \"1: Bugfix\");\n        var message = message(commit);\n        var check = new ProblemListsCheck(REPOSITORY);\n        var issues = toList(check.check(commit, message, conf2, null));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void singlePrefixedAlwaysProblemListedConf2() {\n        var commit = commit(0, \"PROJ-3: Bugfix\");\n        var message = message(commit);\n        var check = new ProblemListsCheck(REPOSITORY);\n        var issues = toList(check.check(commit, message, conf2, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof ProblemListsIssue);\n        var issue = (ProblemListsIssue) issues.get(0);\n        assertEquals(\"PROJ-3\", issue.issue());\n        assertEquals(Set.of(Path.of(\"test/ProjProblemList.txt\")), issue.files());\n    }\n\n    @Test\n    void singlePrefixedUnproblemListedConf2() {\n        var commit = commit(1, \"PROJ-1: Bugfix\");\n        var message = message(commit);\n        var check = new ProblemListsCheck(REPOSITORY);\n        var issues = toList(check.check(commit, message, conf2, null));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void singleAlwaysProblemListedInTwoListsConf3() {\n        var check = new ProblemListsCheck(REPOSITORY);\n\n        {\n            var commit = commit(1, \"2: Bugfix\");\n            var message = message(commit);\n            var issues = toList(check.check(commit, message, conf3, null));\n\n            assertEquals(1, issues.size());\n            assertTrue(issues.get(0) instanceof ProblemListsIssue);\n            var issue = (ProblemListsIssue) issues.get(0);\n            assertEquals(\"2\", issue.issue());\n            assertEquals(Set.of(\n                    Path.of(\"test1/ProblemList.txt\"),\n                    Path.of(\"test1/ProblemList1.txt\"),\n                    Path.of(\"test2/ProblemList.txt\"),\n                    Path.of(\"test2/ProblemList1.txt\")), issue.files());\n        }\n\n        {\n            var commit = commit(2, \"2: Bugfix\");\n            var message = message(commit);\n            var issues = toList(check.check(commit, message, conf3, null));\n\n            assertEquals(1, issues.size());\n            assertTrue(issues.get(0) instanceof ProblemListsIssue);\n            var issue = (ProblemListsIssue) issues.get(0);\n            assertEquals(\"2\", issue.issue());\n            assertEquals(Set.of(\n                    Path.of(\"test1/ProblemList.txt\"),\n                    Path.of(\"test1/ProblemList2.txt\"),\n                    Path.of(\"test2/ProblemList.txt\"),\n                    Path.of(\"test2/ProblemList2.txt\")), issue.files());\n        }\n    }\n\n    @Test\n    void singlePrefixedAlwaysProblemListedConf4() {\n        var check = new ProblemListsCheck(REPOSITORY);\n\n        var commit = commit(0, \"PROJ-2: Bugfix\");\n        var message = message(commit);\n        var issues = toList(check.check(commit, message, conf4, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof ProblemListsIssue);\n        var issue = (ProblemListsIssue) issues.get(0);\n        assertEquals(\"PROJ-2\", issue.issue());\n        assertEquals(Set.of(Path.of(\"test1/ProjProblemList.txt\"),\n                Path.of(\"test2/ProjProblemList.txt\")), issue.files());\n\n    }\n\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/ReviewersCheckTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.census.Census;\nimport org.openjdk.skara.vcs.Author;\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.CommitMetadata;\nimport org.openjdk.skara.vcs.Hash;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageFormatters;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport java.util.Iterator;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.ArrayList;\nimport java.time.ZonedDateTime;\nimport java.io.IOException;\n\nimport static org.openjdk.skara.jcheck.ReviewersConfiguration.BYLAWS_URL;\n\nclass ReviewersCheckTests {\n    private final Utilities utils = new Utilities();\n\n    private static final List<String> CENSUS = List.of(\n        \"<?xml version=\\\"1.0\\\" encoding=\\\"us-ascii\\\"?>\",\n        \"<census time=\\\"2019-03-13T10:29:41-07:00\\\">\",\n        \"  <person name=\\\"foo\\\">\",\n        \"    <full-name>Foo</full-name>\",\n        \"  </person>\",\n        \"  <person name=\\\"bar\\\">\",\n        \"    <full-name>Bar</full-name>\",\n        \"  </person>\",\n        \"  <person name=\\\"baz\\\">\",\n        \"    <full-name>Baz</full-name>\",\n        \"  </person>\",\n        \"  <person name=\\\"qux\\\">\",\n        \"    <full-name>Qux</full-name>\",\n        \"  </person>\",\n        \"  <person name=\\\"contributor\\\">\",\n        \"    <full-name>Contributor</full-name>\",\n        \"  </person>\",\n        \"  <group name=\\\"test\\\">\",\n        \"    <full-name>Test</full-name>\",\n        \"    <person ref=\\\"foo\\\" role=\\\"lead\\\" />\",\n        \"    <person ref=\\\"bar\\\" />\",\n        \"    <person ref=\\\"baz\\\" />\",\n        \"    <person ref=\\\"qux\\\" />\",\n        \"  </group>\",\n        \"  <project name=\\\"test\\\">\",\n        \"    <full-name>Test</full-name>\",\n        \"    <sponsor ref=\\\"test\\\" />\",\n        \"    <person role=\\\"lead\\\" ref=\\\"foo\\\" />\",\n        \"    <person role=\\\"reviewer\\\" ref=\\\"bar\\\" />\",\n        \"    <person role=\\\"committer\\\" ref=\\\"baz\\\" />\",\n        \"    <person role=\\\"author\\\" ref=\\\"qux\\\" />\",\n        \"  </project>\",\n        \"  <project name=\\\"jdk\\\">\",\n        \"    <full-name>TestJDK</full-name>\",\n        \"    <sponsor ref=\\\"test\\\" />\",\n        \"    <person role=\\\"lead\\\" ref=\\\"foo\\\" />\",\n        \"    <person role=\\\"reviewer\\\" ref=\\\"bar\\\" />\",\n        \"    <person role=\\\"committer\\\" ref=\\\"baz\\\" />\",\n        \"    <person role=\\\"author\\\" ref=\\\"qux\\\" />\",\n        \"  </project>\",\n        \"</census>\"\n    );\n\n    private static final List<String> CONFIGURATION = List.of(\n        \"[general]\",\n        \"project = test\",\n        \"[checks]\",\n        \"error = reviewers\",\n        \"[checks \\\"reviewers\\\"]\"\n    );\n\n    private static Commit commit(List<String> reviewers) {\n        return commit(new Author(\"user\", \"user@host.org\"), reviewers);\n    }\n\n    private static Commit commit(Author author, List<String> reviewers) {\n        return commit(author, reviewers, null);\n    }\n\n    private static Commit commit(Author author, List<String> reviewers, Hash original) {\n        var hash = new Hash(\"0123456789012345678901234567890123456789\");\n        var parents = List.of(new Hash(\"12345789012345789012345678901234567890\"));\n        var authored = ZonedDateTime.now();\n\n        var message = CommitMessage.title(\"Initial commit\");\n        message.reviewers(reviewers);\n        if (original != null) {\n            message.original(original);\n        }\n        var desc = message.format(CommitMessageFormatters.v1);\n        var metadata = new CommitMetadata(hash, parents, author, authored, author, authored, desc);\n        return new Commit(metadata, List.of());\n    }\n\n    private static CommitMessage message(Commit c) {\n        return CommitMessageParsers.v1.parse(c);\n    }\n\n    private static Census census() throws IOException {\n        return Census.parse(CENSUS);\n    }\n\n    private static JCheckConfiguration conf() {\n        return conf(1);\n    }\n\n    private static JCheckConfiguration conf(int reviewers) {\n        return conf(reviewers, 0, 0);\n    }\n\n    private static JCheckConfiguration conf(int reviewers, List<String> ignored) {\n        return conf(reviewers, 0, 0, ignored);\n    }\n\n    private static JCheckConfiguration conf(int reviewers, int committers) {\n        return conf(reviewers, committers, 0);\n    }\n\n    private static JCheckConfiguration conf(int reviewers, int committers, int authors) {\n        return conf(reviewers, committers, authors, List.of());\n    }\n\n    private static JCheckConfiguration conf(int reviewers, int committers, int authors, List<String> ignored) {\n        var lines = new ArrayList<String>(CONFIGURATION);\n        lines.add(\"reviewers = \" + reviewers);\n        lines.add(\"committers = \" + committers);\n        lines.add(\"authors = \" + authors);\n        lines.add(\"ignore = \" + String.join(\", \", ignored));\n        return JCheckConfiguration.parse(lines);\n    }\n\n    private List<Issue> toList(Iterator<Issue> i) {\n        var list = new ArrayList<Issue>();\n        while (i.hasNext()) {\n            list.add(i.next());\n        }\n        return list;\n    }\n\n    @Test\n    void singleReviewerShouldPass() throws IOException {\n        var commit = commit(List.of(\"bar\"));\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(1), census()));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void leadAsReviewerShouldPass() throws IOException {\n        var commit = commit(List.of(\"foo\"));\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(1), census()));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void committerAsReviewerShouldFail() throws IOException {\n        var commit = commit(List.of(\"baz\"));\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(1), census()));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof TooFewReviewersIssue);\n        var issue = (TooFewReviewersIssue) issues.get(0);\n        assertEquals(0, issue.numActual());\n        assertEquals(1, issue.numRequired());\n        assertEquals(\"reviewer\", issue.role());\n        assertEquals(commit, issue.commit());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check, issue.check());\n    }\n\n    @Test\n    void authorAsReviewerShouldFail() throws IOException {\n        var commit = commit(List.of(\"qux\"));\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(1), census()));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof TooFewReviewersIssue);\n        var issue = (TooFewReviewersIssue) issues.get(0);\n        assertEquals(0, issue.numActual());\n        assertEquals(1, issue.numRequired());\n        assertEquals(\"reviewer\", issue.role());\n        assertEquals(commit, issue.commit());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check, issue.check());\n    }\n\n    @Test\n    void noReviewersShouldFail() throws IOException {\n        var commit = commit(List.of());\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(1), census()));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof TooFewReviewersIssue);\n        var issue = (TooFewReviewersIssue) issues.get(0);\n        assertEquals(0, issue.numActual());\n        assertEquals(1, issue.numRequired());\n        assertEquals(\"reviewer\", issue.role());\n        assertEquals(commit, issue.commit());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check, issue.check());\n    }\n\n    @Test\n    void multipleInvalidReviewersShouldFail() throws IOException {\n        var commit = commit(List.of(\"qux\", \"baz\"));\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(1), census()));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof TooFewReviewersIssue);\n        var issue = (TooFewReviewersIssue) issues.get(0);\n        assertEquals(0, issue.numActual());\n        assertEquals(1, issue.numRequired());\n        assertEquals(\"reviewer\", issue.role());\n        assertEquals(commit, issue.commit());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check, issue.check());\n    }\n\n    @Test\n    void uknownReviewersShouldFail() throws IOException {\n        var commit = commit(List.of(\"unknown\", \"user\"));\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(1), census()));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof InvalidReviewersIssue);\n        var issue = (InvalidReviewersIssue) issues.get(0);\n        assertEquals(List.of(\"unknown\", \"user\"), issue.invalid());\n        assertEquals(commit, issue.commit());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check, issue.check());\n    }\n\n    @Test\n    void oneReviewerAndMultipleInvalidReviewersShouldPass() throws IOException {\n        var commit = commit(List.of(\"bar\", \"baz\", \"qux\"));\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(1), census()));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void oneReviewerAndUknownReviewerShouldFail() throws IOException {\n        var commit = commit(List.of(\"bar\", \"unknown\"));\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(1), census()));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof InvalidReviewersIssue);\n        var issue = (InvalidReviewersIssue) issues.get(0);\n        assertEquals(List.of(\"unknown\"), issue.invalid());\n        assertEquals(commit, issue.commit());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check, issue.check());\n    }\n\n    @Test\n    void zeroReviewersConfigurationShouldPass() throws IOException {\n        var commit = commit(new ArrayList<String>());\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(0), census()));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void selfReviewShouldNotPass() throws IOException {\n        var commit = commit(new Author(\"bar\", \"bar@localhost\"), List.of(\"bar\"));\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(1), census()));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof SelfReviewIssue);\n        var issue = (SelfReviewIssue) issues.get(0);\n        assertEquals(commit, issue.commit());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check, issue.check());\n    }\n\n    @Test\n    void ignoredReviewersShouldBeExcluded() throws IOException {\n        var ignored = List.of(\"foo\", \"bar\");\n        var commit = commit(ignored);\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(1, ignored), census()));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof TooFewReviewersIssue);\n    }\n\n    @Test\n    void requiringCommitterAndReviwerShouldPass() throws IOException {\n        var commit = commit(List.of(\"bar\", \"baz\"));\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(1, 1), census()));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void missingRoleShouldFail() throws IOException {\n        var commit = commit(List.of(\"bar\", \"qux\"));\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(1, 1), census()));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof TooFewReviewersIssue);\n        var issue = (TooFewReviewersIssue) issues.get(0);\n        assertEquals(0, issue.numActual());\n        assertEquals(1, issue.numRequired());\n        assertEquals(\"committer\", issue.role());\n        assertEquals(commit, issue.commit());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check, issue.check());\n    }\n\n    @Test\n    void relaxedRoleShouldPass() throws IOException {\n        var commit = commit(List.of(\"bar\", \"qux\"));\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(0, 1, 1), census()));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void relaxedRoleAndMissingRoleShouldFail() throws IOException {\n        var commit = commit(List.of(\"bar\", \"contributor\"));\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(0, 1, 1), census()));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof TooFewReviewersIssue);\n        var issue = (TooFewReviewersIssue) issues.get(0);\n        assertEquals(0, issue.numActual());\n        assertEquals(1, issue.numRequired());\n        assertEquals(\"author\", issue.role());\n        assertEquals(commit, issue.commit());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check, issue.check());\n    }\n\n    @Test\n    void legacyConfigurationShouldWork() throws IOException {\n        var commit = commit(List.of(\"bar\"));\n        var check = new ReviewersCheck(utils);\n        var legacyConf = new ArrayList<>(CONFIGURATION);\n        legacyConf.add(\"minimum = 1\");\n        legacyConf.add(\"role = reviewer\");\n        var issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(legacyConf), census()));\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void legacyConfigurationShouldAcceptRole() throws IOException {\n        var commit = commit(List.of(\"baz\"));\n        var check = new ReviewersCheck(utils);\n        var legacyConf = new ArrayList<>(CONFIGURATION);\n        legacyConf.add(\"minimum = 1\");\n        legacyConf.add(\"role = reviewer\");\n        var issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(legacyConf), census()));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof TooFewReviewersIssue);\n        var issue = (TooFewReviewersIssue) issues.get(0);\n        assertEquals(0, issue.numActual());\n        assertEquals(1, issue.numRequired());\n        assertEquals(\"reviewer\", issue.role());\n        assertEquals(commit, issue.commit());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check, issue.check());\n    }\n\n    @Test\n    void legacyConfigurationShouldAcceptCommitterRole() throws IOException {\n        var commit = commit(List.of(\"foo\"));\n        var check = new ReviewersCheck(utils);\n        var legacyConf = new ArrayList<>(CONFIGURATION);\n        legacyConf.add(\"minimum = 1\");\n        legacyConf.add(\"role = committer\");\n\n        var issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(legacyConf), census()));\n        assertEquals(0, issues.size());\n\n        commit = commit(List.of(\"bar\"));\n        issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(legacyConf), census()));\n        assertEquals(0, issues.size());\n\n        commit = commit(List.of(\"baz\"));\n        issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(legacyConf), census()));\n        assertEquals(0, issues.size());\n\n        commit = commit(List.of(\"qux\"));\n        issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(legacyConf), census()));\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof TooFewReviewersIssue);\n        var issue = (TooFewReviewersIssue) issues.get(0);\n        assertEquals(0, issue.numActual());\n        assertEquals(1, issue.numRequired());\n        assertEquals(\"committer\", issue.role());\n        assertEquals(commit, issue.commit());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check, issue.check());\n    }\n\n    @Test\n    void modernConfigurationShouldAcceptCommitterRole() throws IOException {\n        var commit = commit(List.of(\"foo\"));\n        var check = new ReviewersCheck(utils);\n        var modernConf = new ArrayList<>(CONFIGURATION);\n        modernConf.add(\"committers = 1\");\n\n        var issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(modernConf), census()));\n        assertEquals(0, issues.size());\n\n        commit = commit(List.of(\"bar\"));\n        issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(modernConf), census()));\n        assertEquals(0, issues.size());\n\n        commit = commit(List.of(\"baz\"));\n        issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(modernConf), census()));\n        assertEquals(0, issues.size());\n\n        commit = commit(List.of(\"qux\"));\n        issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(modernConf), census()));\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof TooFewReviewersIssue);\n        var issue = (TooFewReviewersIssue) issues.get(0);\n        assertEquals(0, issue.numActual());\n        assertEquals(1, issue.numRequired());\n        assertEquals(\"committer\", issue.role());\n        assertEquals(commit, issue.commit());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check, issue.check());\n    }\n\n    @Test\n    void oldJDKConfigurationShouldRequireContributor() throws IOException {\n        var commit = commit(List.of(\"foo\"));\n        var check = new ReviewersCheck(utils);\n        var oldJDKConf = new ArrayList<String>();\n        oldJDKConf.add(\"project=jdk\");\n        oldJDKConf.add(\"bugids=dup\");\n\n        var issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(oldJDKConf), census()));\n        assertEquals(0, issues.size());\n\n        commit = commit(List.of(\"bar\"));\n        issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(oldJDKConf), census()));\n        assertEquals(0, issues.size());\n\n        commit = commit(List.of(\"baz\"));\n        issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(oldJDKConf), census()));\n        assertEquals(0, issues.size());\n\n        commit = commit(List.of(\"qux\"));\n        issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(oldJDKConf), census()));\n        assertEquals(0, issues.size());\n\n        commit = commit(List.of(\"contributor\"));\n        issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(oldJDKConf), census()));\n        assertEquals(0, issues.size());\n\n        commit = commit(List.of());\n        issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(oldJDKConf), census()));\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof TooFewReviewersIssue);\n        var issue = (TooFewReviewersIssue) issues.get(0);\n        assertEquals(0, issue.numActual());\n        assertEquals(1, issue.numRequired());\n        assertEquals(\"contributor\", issue.role());\n        assertEquals(commit, issue.commit());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check, issue.check());\n\n        commit = commit(List.of(\"unknown\"));\n        issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(oldJDKConf), census()));\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof InvalidReviewersIssue);\n        var invalidIssue = (InvalidReviewersIssue) issues.get(0);\n        assertEquals(List.of(\"unknown\"), invalidIssue.invalid());\n        assertEquals(commit, invalidIssue.commit());\n        assertEquals(Severity.ERROR, invalidIssue.severity());\n        assertEquals(check, invalidIssue.check());\n    }\n\n    @Test\n    void backportCommitWithoutReviewersIsFine() throws IOException {\n        var original = new Hash(\"0123456789012345678901234567890123456789\");\n        var commit = commit(new Author(\"user\", \"user@host.org\"), List.of(), original);\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), conf(1), census()));\n        assertEquals(List.of(), issues);\n    }\n\n    @Test\n    void backportCommitWithoutReviewersWithIgnoredCheckIsFine() throws IOException {\n        var conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 1\");\n        conf.add(\"backports = ignore\");\n        var original = new Hash(\"0123456789012345678901234567890123456789\");\n        var commit = commit(new Author(\"user\", \"user@host.org\"), List.of(), original);\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(conf), census()));\n        assertEquals(List.of(), issues);\n    }\n\n    @Test\n    void backportCommitWithoutReviewersWithStrictCheckingIsError() throws IOException {\n        var conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 1\");\n        conf.add(\"backports = check\");\n        var original = new Hash(\"0123456789012345678901234567890123456789\");\n        var commit = commit(new Author(\"user\", \"user@host.org\"), List.of(), original);\n        var check = new ReviewersCheck(utils);\n        var issues = toList(check.check(commit, message(commit), JCheckConfiguration.parse(conf), census()));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof TooFewReviewersIssue);\n        var issue = (TooFewReviewersIssue) issues.get(0);\n        assertEquals(0, issue.numActual());\n        assertEquals(1, issue.numRequired());\n        assertEquals(\"reviewer\", issue.role());\n        assertEquals(commit, issue.commit());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(check, issue.check());\n    }\n\n    @Test\n    void testReviewRequirements() throws IOException {\n        var conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 0\");\n        assertEquals(constructReviewRequirement(0, 0, 0, 0, 0), JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        // one review required.\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 1\");\n        assertEquals(constructReviewRequirement(0, 1, 0, 0, 0),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"committers = 1\");\n        assertEquals(constructReviewRequirement(0, 0, 1, 0, 0),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        // two reviews required.\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 1\");\n        conf.add(\"committers = 1\");\n        assertEquals(constructReviewRequirement(0, 1, 1, 0, 0),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 2\");\n        assertEquals(constructReviewRequirement(0, 2, 0, 0, 0),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        // three reviews required.\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 1\");\n        conf.add(\"committers = 1\");\n        conf.add(\"authors = 1\");\n        assertEquals(constructReviewRequirement(0, 1, 1, 1, 0),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 1\");\n        conf.add(\"committers = 2\");\n        assertEquals(constructReviewRequirement(0, 1, 2, 0, 0),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"committers = 3\");\n        assertEquals(constructReviewRequirement(0, 0, 3, 0, 0),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        // four reviews required.\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 1\");\n        conf.add(\"committers = 1\");\n        conf.add(\"authors = 1\");\n        conf.add(\"contributors = 1\");\n        assertEquals(constructReviewRequirement(0, 1, 1, 1, 1),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 1\");\n        conf.add(\"committers = 1\");\n        conf.add(\"authors = 2\");\n        assertEquals(constructReviewRequirement(0, 1, 1, 2, 0),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 1\");\n        conf.add(\"authors = 3\");\n        assertEquals(constructReviewRequirement(0, 1, 0, 3, 0),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"authors = 4\");\n        assertEquals(constructReviewRequirement(0, 0, 0, 4, 0),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        // five reviews required.\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"lead = 1\");\n        conf.add(\"reviewers = 1\");\n        conf.add(\"committers = 1\");\n        conf.add(\"authors = 1\");\n        conf.add(\"contributors = 1\");\n        assertEquals(constructReviewRequirement(1, 1, 1, 1, 1),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 1\");\n        conf.add(\"committers = 1\");\n        conf.add(\"authors = 1\");\n        conf.add(\"contributors = 2\");\n        assertEquals(constructReviewRequirement(0, 1, 1, 1, 2),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 1\");\n        conf.add(\"committers = 1\");\n        conf.add(\"contributors = 3\");\n        assertEquals(constructReviewRequirement(0, 1, 1, 0, 3),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 1\");\n        conf.add(\"contributors = 4\");\n        assertEquals(constructReviewRequirement(0, 1, 0, 0, 4),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n\n        conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"contributors = 5\");\n        assertEquals(constructReviewRequirement(0, 0, 0, 0, 5),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n    }\n\n    private String constructReviewRequirement(int leadNum, int reviewerNum, int committerNum, int authorNum, int contributorNum) {\n        // no review required.\n        var noReview = \"no review required\";\n        // review required template.\n        var hasReview = \"%d review%s required, with at least %s\";\n        var totalNum = leadNum + reviewerNum + committerNum + authorNum + contributorNum;\n        if (totalNum == 0) {\n            return noReview;\n        }\n        var requireList = new ArrayList<String>();\n        var reviewRequirementMap = new LinkedHashMap<String, Integer>();\n        reviewRequirementMap.put(\"[Lead%s](%s#project-lead)\", leadNum);\n        reviewRequirementMap.put(\"[Reviewer%s](%s#reviewer)\", reviewerNum);\n        reviewRequirementMap.put(\"[Committer%s](%s#committer)\", committerNum);\n        reviewRequirementMap.put(\"[Author%s](%s#author)\", authorNum);\n        reviewRequirementMap.put(\"[Contributor%s](%s#contributor)\", contributorNum);\n        for (var reviewRequirement : reviewRequirementMap.entrySet()) {\n            var requirementNum = reviewRequirement.getValue();\n            if (requirementNum > 0) {\n                requireList.add(requirementNum+ \" \" + String.format(reviewRequirement.getKey(), requirementNum > 1 ? \"s\" : \"\", BYLAWS_URL));\n            }\n        }\n        return String.format(hasReview, totalNum, totalNum > 1 ? \"s\" : \"\", String.join(\", \", requireList));\n    }\n\n    @Test\n    void minimumCanBeDisabled() {\n        var conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 1\");\n        conf.add(\"minimum = disable\");\n        assertEquals(constructReviewRequirement(0, 1, 0, 0, 0),\n                JCheckConfiguration.parse(conf).checks().reviewers().getReviewRequirements());\n    }\n\n    @Test\n    void minimumWithAnotherRoleTrows() {\n        var conf = new ArrayList<>(CONFIGURATION);\n        conf.add(\"reviewers = 1\");\n        conf.add(\"minimum = 1\");\n        assertThrows(IllegalStateException.class, () -> JCheckConfiguration.parse(conf));\n    }\n\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/SymlinkCheckTests.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.*;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.ArrayList;\nimport java.time.ZonedDateTime;\nimport java.io.IOException;\nimport java.nio.file.Path;\n\nclass SymlinkCheckTests {\n    private final Utilities utils = new Utilities();\n\n    private static final List<String> CONFIGURATION = List.of(\n        \"[general]\",\n        \"project = test\",\n        \"[checks]\",\n        \"error = symlink\"\n    );\n\n    private static JCheckConfiguration conf() {\n        return JCheckConfiguration.parse(CONFIGURATION);\n    }\n\n    private static List<Diff> symlinkDiff(String filename) {\n        var patch = new TextualPatch(null, null, Hash.zero(),\n                                     Path.of(filename), FileType.fromOctal(\"120000\"), Hash.zero(),\n                                     Status.from('A'), List.of());\n        var diff = new Diff(Hash.zero(), Hash.zero(), List.of(patch));\n        return List.of(diff);\n    }\n\n    private static List<Diff> diff(String filename, String line) {\n        var hunk = new Hunk(new Range(1, 0), List.of(),\n                            new Range(1, 1), List.of(line));\n        var patch = new TextualPatch(Path.of(filename), FileType.fromOctal(\"100644\"), Hash.zero(),\n                                     Path.of(filename), FileType.fromOctal(\"100644\"), Hash.zero(),\n                                     Status.from('M'), List.of(hunk));\n        var diff = new Diff(Hash.zero(), Hash.zero(), List.of(patch));\n        return List.of(diff);\n    }\n\n    private static Commit commit(List<Diff> diffs) {\n        var author = new Author(\"foo\", \"foo@localhost\");\n        var hash = new Hash(\"0123456789012345678901234567890123456789\");\n        var parents = List.of(hash);\n        var authored = ZonedDateTime.now();\n        var metadata = new CommitMetadata(hash, parents, author, authored, author, authored, List.of(\"Added symlink\"));\n        return new Commit(metadata, diffs);\n    }\n\n    private static Commit commitWithSymlink(String filename) {\n        return commit(symlinkDiff(filename));\n    }\n\n    private static Commit commitWithRegularFile(String filename, String line) {\n        return commit(diff(filename, line));\n    }\n\n    private static CommitMessage message(Commit c) {\n        return CommitMessageParsers.v1.parse(c);\n    }\n\n    private List<Issue> toList(Iterator<Issue> i) {\n        var list = new ArrayList<Issue>();\n        while (i.hasNext()) {\n            list.add(i.next());\n        }\n        return list;\n    }\n\n    @Test\n    void commitWithSymlinkShouldFail() {\n        var commit = commitWithSymlink(\"symlink\");\n        var message = message(commit);\n        var check = new SymlinkCheck();\n        var issues = toList(check.check(commit, message, conf(), null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof SymlinkIssue);\n        var issue = (SymlinkIssue) issues.get(0);\n        assertEquals(\"symlink\", issue.path().toString());\n    }\n\n    @Test\n    void commitWithoutSymlinkShouldPass() {\n        var commit = commitWithRegularFile(\"README.txt\", \"Hello, world\");\n        var message = message(commit);\n        var check = new SymlinkCheck();\n        var issues = toList(check.check(commit, message, conf(), null));\n        assertEquals(List.of(), issues);\n    }\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/TagsCheckTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.Tag;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport java.io.IOException;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.ArrayList;\nimport java.util.regex.Pattern;\n\nclass TagsCheckTests {\n    private List<Issue> toList(Iterator<Issue> i) {\n        var list = new ArrayList<Issue>();\n        while (i.hasNext()) {\n            list.add(i.next());\n        }\n        return list;\n    }\n\n    @Test\n    void onlyDefaultTagShouldPass() throws IOException {\n        var repo = new TestRepository();\n        repo.setDefaultTag(new Tag(\"default\"));\n        repo.setTags(List.of(new Tag(\"default\")));\n\n        var allowNothing = Pattern.compile(\"\");\n        var check = new TagsCheck(allowNothing);\n        var issues = toList(check.check(repo));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void allowedTagShouldPass() throws IOException {\n        var repo = new TestRepository();\n        var allowed = \"jdk-19\";\n        repo.setTags(List.of(new Tag(\"jdk-19\")));\n\n        var check = new TagsCheck(Pattern.compile(allowed));\n        var issues = toList(check.check(repo));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void additionalTagsShouldFail() throws IOException {\n        var repo = new TestRepository();\n        var additional = new Tag(\"foo\");\n        repo.setTags(List.of(additional));\n\n        var allowNothing = Pattern.compile(\"\");\n        var check = new TagsCheck(allowNothing);\n        var issues = toList(check.check(repo));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof TagIssue);\n        var issue = (TagIssue) issues.get(0);\n        assertEquals(additional, issue.tag());\n        assertEquals(Severity.ERROR, issue.severity());\n        assertEquals(TagsCheck.class, issue.check().getClass());\n    }\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/TestRepository.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.*;\n\nclass TestRepository implements ReadOnlyRepository {\n    private Branch currentBranch = null;\n    private Branch defaultBranch = null;\n    private List<Branch> branches = new ArrayList<Branch>();\n\n    private Tag defaultTag = null;\n    private List<Tag> tags = new ArrayList<Tag>();\n\n    public Optional<Branch> currentBranch() throws IOException {\n        return Optional.empty();\n    }\n\n    void setCurrentBranch(Branch branch) {\n        currentBranch = branch;\n    }\n\n    public Optional<Bookmark> currentBookmark() {\n        return Optional.empty();\n    }\n\n    public Branch defaultBranch() throws IOException {\n        return defaultBranch;\n    }\n\n    void setDefaultBranch(Branch branch) throws IOException {\n        defaultBranch = branch;\n    }\n\n    public List<Branch> branches() throws IOException {\n        return branches;\n    }\n\n    @Override\n    public List<Branch> branches(String remote) throws IOException {\n        return branches;\n    }\n\n    void setBranches(List<Branch> branches) {\n        this.branches = branches;\n    }\n\n    public Optional<Tag> defaultTag() throws IOException {\n        return Optional.ofNullable(defaultTag);\n    }\n\n    void setDefaultTag(Tag tag) {\n        defaultTag = tag;\n    }\n\n    public List<Tag> tags() throws IOException {\n        return tags;\n    }\n\n    void setTags(List<Tag> tags) {\n        this.tags = tags;\n    }\n\n    public Hash head() throws IOException {\n        return null;\n    }\n\n    public Commits commits() throws IOException {\n        return null;\n    }\n\n    @Override\n    public Commits commits(int n) throws IOException {\n        return null;\n    }\n\n    public Commits commits(boolean reverse) throws IOException {\n        return null;\n    }\n\n    @Override\n    public Commits commits(int n, boolean reverse) throws IOException {\n        return null;\n    }\n\n    public Commits commits(String range) throws IOException {\n        return null;\n    }\n\n    public Commits commits(String range, boolean reverse) throws IOException {\n        return null;\n    }\n\n    @Override\n    public Commits commits(String range, int n) throws IOException {\n        return null;\n    }\n\n    @Override\n    public Commits commits(String range, int n, boolean reverse) throws IOException {\n        return null;\n    }\n\n    @Override\n    public Commits commits(List<Hash> reachableFrom, List<Hash> unreachableFrom) throws IOException {\n        return null;\n    }\n\n    public Optional<Commit> lookup(Hash h) throws IOException {\n        return Optional.empty();\n    }\n\n    public Optional<Commit> lookup(Branch b) throws IOException {\n        return Optional.empty();\n    }\n\n    public Optional<Commit> lookup(Tag t) throws IOException {\n        return Optional.empty();\n    }\n\n    public List<CommitMetadata> commitMetadata(Hash from, Hash to) throws IOException {\n        return List.of();\n    }\n\n    public List<CommitMetadata> commitMetadata(String range, boolean reverse) throws IOException {\n        return List.of();\n    }\n\n    public List<CommitMetadata> commitMetadata(Hash from, Hash to, boolean reverse) throws IOException {\n        return List.of();\n    }\n\n    public List<CommitMetadata> commitMetadata(List<Path> paths) throws IOException {\n        return List.of();\n    }\n\n    public List<CommitMetadata> commitMetadata(List<Path> paths, boolean reverse) throws IOException {\n        return List.of();\n    }\n\n    public List<CommitMetadata> commitMetadata(String range, List<Path> paths) throws IOException {\n        return List.of();\n    }\n\n    public List<CommitMetadata> commitMetadata(Hash from, Hash to, List<Path> paths) throws IOException {\n        return List.of();\n    }\n\n    public List<CommitMetadata> commitMetadata(String range, List<Path> paths, boolean reverse) throws IOException {\n        return List.of();\n    }\n\n    public List<CommitMetadata> commitMetadata(Hash from, Hash to, List<Path> paths, boolean reverse) throws IOException {\n        return List.of();\n    }\n\n    public List<CommitMetadata> commitMetadata(boolean reverse) throws IOException {\n        return List.of();\n    }\n\n    public List<CommitMetadata> commitMetadata(String range) throws IOException {\n        return List.of();\n    }\n\n    public List<CommitMetadata> commitMetadata() throws IOException {\n        return List.of();\n    }\n\n    public List<CommitMetadata> commitMetadataFor(List<Branch> branches) throws IOException {\n        return List.of();\n    }\n\n    public Path root() throws IOException {\n        return null;\n    }\n\n    public boolean exists() throws IOException {\n        return false;\n    }\n\n    public boolean isHealthy() throws IOException {\n        return false;\n    }\n\n    public boolean isEmpty() throws IOException {\n        return true;\n    }\n\n    @Override\n    public boolean isClean() throws IOException {\n        return true;\n    }\n\n    public Hash mergeBase(Hash first, Hash second) throws IOException {\n        return null;\n    }\n\n    @Override\n    public boolean isAncestor(Hash ancestor, Hash descendant) throws IOException {\n        return false;\n    }\n\n    public Optional<Hash> resolve(String ref) throws IOException {\n        return Optional.empty();\n    }\n\n    public Optional<String> username() throws IOException {\n        return Optional.empty();\n    }\n\n    public Optional<byte[]> show(Path p, Hash h) throws IOException {\n        return Optional.of(new byte[0]);\n    }\n\n    public List<FileEntry> files(Hash h, List<Path> paths) throws IOException {\n        return List.of();\n    }\n\n    public void dump(FileEntry entry, Path to) throws IOException {\n    }\n\n    public Diff diff(Hash base, Hash head, int similarity) throws IOException {\n        return null;\n    }\n\n    public Diff diff(Hash base, Hash head, List<Path> files, int similarity) throws IOException {\n        return null;\n    }\n\n    public Diff diff(Hash head, int similarity) throws IOException {\n        return null;\n    }\n\n    public Diff diff(Hash head, List<Path> files, int similarity) throws IOException {\n        return null;\n    }\n\n    public List<String> config(String key) throws IOException {\n        return null;\n    }\n\n    public Repository copyTo(Path destination) throws IOException {\n        return null;\n    }\n\n    public String pullPath(String remote) throws IOException {\n        return null;\n    }\n\n    public String pushPath(String remote) throws IOException {\n        return null;\n    }\n\n    public boolean isValidRevisionRange(String expression) throws IOException {\n        return false;\n    }\n\n    public Optional<String> upstreamFor(Branch b) throws IOException {\n        return Optional.empty();\n    }\n\n    public List<StatusEntry> status(Hash from, Hash to) throws IOException {\n        return Collections.emptyList();\n    }\n\n    public List<StatusEntry> status() throws IOException {\n        return Collections.emptyList();\n    }\n\n    public boolean contains(Branch b, Hash h) throws IOException {\n        return false;\n    }\n\n    public List<Reference> remoteBranches(String remote) throws IOException {\n        return null;\n    }\n\n    public List<String> remotes() throws IOException {\n        return null;\n    }\n\n    public void addSubmodule(String pullPath, Path path) throws IOException {\n    }\n\n    public List<Submodule> submodules() throws IOException {\n        return null;\n    }\n\n    @Override\n    public Tree tree(Hash h) throws IOException {\n        return null;\n    }\n\n    public Optional<Tag.Annotated> annotate(Tag tag) throws IOException {\n        return null;\n    }\n\n    public String range(Hash h) {\n        return null;\n    }\n\n    public String rangeInclusive(Hash from, Hash to) {\n        return null;\n    }\n\n    public String rangeExclusive(Hash from, Hash to) {\n        return null;\n    }\n\n    public List<CommitMetadata> follow(Path path) {\n        return List.of();\n    }\n\n    public List<CommitMetadata> follow(Path path, Hash from, Hash to) {\n        return List.of();\n    }\n\n    public boolean contains(Hash h) {\n        return false;\n    }\n\n    @Override\n    public int commitCount() throws IOException {\n        return 0;\n    }\n\n    @Override\n    public int commitCount(List<Branch> branches) throws IOException {\n        return 0;\n    }\n\n    @Override\n    public Hash initialHash() {\n        return null;\n    }\n\n    @Override\n    public Optional<List<String>> stagedFileContents(Path p) {\n        return Optional.empty();\n    }\n\n    @Override\n    public Commit staged() {\n        return null;\n    }\n\n    @Override\n    public Commit workingTree() {\n        return null;\n    }\n}\n"
  },
  {
    "path": "jcheck/src/test/java/org/openjdk/skara/jcheck/WhitespaceCheckTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.jcheck;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.CommitMessage;\nimport org.openjdk.skara.vcs.openjdk.CommitMessageParsers;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport java.nio.file.Path;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.ArrayList;\nimport java.time.ZonedDateTime;\n\nclass WhitespaceCheckTests {\n    private static List<Diff> parentDiffs(String filename, String line) {\n        var hunk = new Hunk(new Range(1, 0), List.of(),\n                            new Range(1, 1), List.of(line));\n        var patch = new TextualPatch(Path.of(filename), FileType.fromOctal(\"100644\"), Hash.zero(),\n                                     Path.of(filename), FileType.fromOctal(\"100644\"), Hash.zero(),\n                                     Status.from('M'), List.of(hunk));\n        var diff = new Diff(Hash.zero(), Hash.zero(), List.of(patch));\n        return List.of(diff);\n    }\n\n    private static final List<String> CONFIGURATION = List.of(\n        \"[general]\",\n        \"project = test\",\n        \"[checks]\",\n        \"error = whitespace\",\n        \"[checks \\\"whitespace\\\"]\"\n    );\n\n    private static JCheckConfiguration configuration(String files, String ignoreTabs) {\n        var lines = new ArrayList<>(CONFIGURATION);\n        lines.add(\"files = \" + files);\n        lines.add(\"ignore-tabs = \" + ignoreTabs);\n        return JCheckConfiguration.parse(lines);\n    }\n\n    private static Commit commit(List<Diff> parentDiffs) {\n        var author = new Author(\"Foo Bar\", \"foo@bar.org\");\n        var hash = new Hash(\"0123456789012345678901234567890123456789\");\n        var parents = List.of(new Hash(\"12345789012345789012345678901234567890\"));\n        var authored = ZonedDateTime.now();\n        var message = List.of(\"Initial commit\", \"\", \"Reviewed-by: baz\");\n        var metadata = new CommitMetadata(hash, parents, author, authored, author, authored, message);\n        return new Commit(metadata, parentDiffs);\n    }\n\n    private static CommitMessage message(Commit c) {\n        return CommitMessageParsers.v1.parse(c);\n    }\n\n    private List<Issue> toList(Iterator<Issue> i) {\n        var list = new ArrayList<Issue>();\n        while (i.hasNext()) {\n            list.add(i.next());\n        }\n        return list;\n    }\n\n    @Test\n    void noBadWhitespaceShouldPass() {\n        var commit = commit(parentDiffs(\"README.md\", \"An additional line\"));\n        var conf = configuration(\"README\\\\.md\", \"\");\n        var check = new WhitespaceCheck();\n        var issues = toList(check.check(commit, message(commit), conf, null));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void trailingWhitespaceShouldFail() {\n        var filename = \"README.md\";\n        var line = \"An additional line \";\n        var commit = commit(parentDiffs(filename, line));\n        var conf = configuration(\"README\\\\.md\", \"\");\n        var message = message(commit);\n        var check = new WhitespaceCheck();\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof WhitespaceIssue);\n        var issue = (WhitespaceIssue) issues.get(0);\n        assertEquals(Path.of(filename), issue.path());\n        assertEquals(1, issue.row());\n        assertEquals(line, issue.line());\n        assertEquals(List.of(new WhitespaceIssue.Error(line.length() - 1, WhitespaceIssue.Whitespace.TRAILING)),\n                     issue.errors());\n        assertEquals(commit, issue.commit());\n        assertEquals(check, issue.check());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n\n    @Test\n    void trailingTabShouldFailWithoutIgnoreTabs() {\n        var filename = \"README.md\";\n        var line = \"An additional line\\t\";\n        var commit = commit(parentDiffs(filename, line));\n        var conf = configuration(\"README\\\\.md\", \"\");\n        var message = message(commit);\n        var check = new WhitespaceCheck();\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof WhitespaceIssue);\n        var issue = (WhitespaceIssue) issues.get(0);\n        assertEquals(Path.of(filename), issue.path());\n        assertEquals(1, issue.row());\n        assertEquals(line, issue.line());\n        assertEquals(List.of(new WhitespaceIssue.Error(line.length() - 1, WhitespaceIssue.Whitespace.TRAILING)),\n                issue.errors());\n        assertEquals(commit, issue.commit());\n        assertEquals(check, issue.check());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n\n    @Test\n    void trailingTabShouldFailWithIgnoreTabs() {\n        var filename = \"README.md\";\n        var line = \"An additional line\\t\";\n        var commit = commit(parentDiffs(filename, line));\n        var conf = configuration(\"README\\\\.md\", \"\\\"README\\\\\\\\.md\");\n        var message = message(commit);\n        var check = new WhitespaceCheck();\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof WhitespaceIssue);\n        var issue = (WhitespaceIssue) issues.get(0);\n        assertEquals(Path.of(filename), issue.path());\n        assertEquals(1, issue.row());\n        assertEquals(line, issue.line());\n        assertEquals(List.of(new WhitespaceIssue.Error(line.length() - 1, WhitespaceIssue.Whitespace.TRAILING)),\n                issue.errors());\n        assertEquals(commit, issue.commit());\n        assertEquals(check, issue.check());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n\n    @Test\n    void tabShouldFailWithoutIgnoreTabs() {\n        var filename = \"README.md\";\n        var line = \"\\tAn additional line\";\n        var commit = commit(parentDiffs(filename, line));\n        var conf = configuration(\"README\\\\.md\", \"\");\n        var message = message(commit);\n        var check = new WhitespaceCheck();\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof WhitespaceIssue);\n        var issue = (WhitespaceIssue) issues.get(0);\n        assertEquals(Path.of(filename), issue.path());\n        assertEquals(1, issue.row());\n        assertEquals(line, issue.line());\n        assertEquals(List.of(new WhitespaceIssue.Error(0, WhitespaceIssue.Whitespace.TAB)),\n                     issue.errors());\n        assertEquals(commit, issue.commit());\n        assertEquals(check, issue.check());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n\n    @Test\n    void tabShouldSucceedWithIgnoreTabs() {\n        var filename = \"README.md\";\n        var line = \"\\tAn additional line\";\n        var commit = commit(parentDiffs(filename, line));\n        var conf = configuration(\"README\\\\.md\", \"README\\\\.md\");\n        var message = message(commit);\n        var check = new WhitespaceCheck();\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(0, issues.size());\n    }\n\n    @Test\n    void crShouldFail() {\n        var filename = \"README.md\";\n        var line = \"An additional line\\r\\n\";\n        var commit = commit(parentDiffs(filename, line));\n        var conf = configuration(\"README\\\\.md\", \"\");\n        var message = message(commit);\n        var check = new WhitespaceCheck();\n        var issues = toList(check.check(commit, message, conf, null));\n\n        assertEquals(1, issues.size());\n        assertTrue(issues.get(0) instanceof WhitespaceIssue);\n        var issue = (WhitespaceIssue) issues.get(0);\n        assertEquals(Path.of(filename), issue.path());\n        assertEquals(1, issue.row());\n        assertEquals(line, issue.line());\n        assertEquals(List.of(new WhitespaceIssue.Error(line.length() - 2, WhitespaceIssue.Whitespace.CR)),\n                     issue.errors());\n        assertEquals(commit, issue.commit());\n        assertEquals(check, issue.check());\n        assertEquals(message, issue.message());\n        assertEquals(Severity.ERROR, issue.severity());\n    }\n}\n"
  },
  {
    "path": "json/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.json'\n    test {\n        requires 'org.junit.jupiter.api'\n        opens 'org.openjdk.skara.json' to 'org.junit.platform.commons'\n    }\n}\n\npublishing {\n    publications {\n        json(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "json/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.json {\n    exports org.openjdk.skara.json;\n}\n"
  },
  {
    "path": "json/src/main/java/org/openjdk/skara/json/JSON.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.json;\n\npublic class JSON {\n    public static JSONValue parse(String s) {\n        return new JSONParser().parse(s);\n    }\n\n    public static JSONValue of(int i) {\n        return JSONValue.from(i);\n    }\n\n    public static JSONValue of(long l) {\n        return JSONValue.from(l);\n    }\n\n    public static JSONValue of(double d) {\n        return JSONValue.from(d);\n    }\n\n    public static JSONValue of(boolean b) {\n        return JSONValue.from(b);\n    }\n\n    public static JSONValue of(String s) {\n        return JSONValue.from(s);\n    }\n\n    public static JSONValue of() {\n        return JSONValue.fromNull();\n    }\n\n    public static JSONArray array() {\n        return new JSONArray();\n    }\n\n    public static JSONObject object() {\n        return new JSONObject();\n    }\n}\n"
  },
  {
    "path": "json/src/main/java/org/openjdk/skara/json/JSONArray.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.json;\n\nimport java.util.*;\nimport java.util.stream.Stream;\n\npublic class JSONArray implements JSONValue, Iterable<JSONValue> {\n    private final List<JSONValue> values;\n\n    public JSONArray() {\n        this.values = new ArrayList<>();\n    }\n\n    public JSONArray(JSONValue[] array) {\n        this.values = new ArrayList<>(array.length);\n        for (var v : array) {\n            values.add(v);\n        }\n    }\n\n    public JSONArray(List<JSONValue> values) {\n        this.values = new ArrayList<>(values);\n    }\n\n    @Override\n    public boolean isArray() {\n        return true;\n    }\n\n    @Override\n    public JSONArray asArray() {\n        return this;\n    }\n\n    public JSONArray set(int i, boolean value) {\n        values.set(i, JSON.of(value));\n        return this;\n    }\n\n    public JSONArray set(int i, int value) {\n        values.set(i, JSON.of(value));\n        return this;\n    }\n\n    public JSONArray set(int i, long value) {\n        values.set(i, JSON.of(value));\n        return this;\n    }\n\n    public JSONArray set(int i, String value) {\n        values.set(i, JSON.of(value));\n        return this;\n    }\n\n    public JSONArray set(int i, double value) {\n        values.set(i, JSON.of(value));\n        return this;\n    }\n\n    public JSONArray set(int i, JSONValue value) {\n        values.set(i, value);\n        return this;\n    }\n\n    public JSONArray setNull(int i) {\n        values.set(i, JSON.of());\n        return this;\n    }\n\n    public JSONArray add(boolean value) {\n        values.add(JSON.of(value));\n        return this;\n    }\n\n    public JSONArray add(int value) {\n        values.add(JSON.of(value));\n        return this;\n    }\n\n    public JSONArray add(long value) {\n        values.add(JSON.of(value));\n        return this;\n    }\n\n    public JSONArray add(String value) {\n        values.add(JSON.of(value));\n        return this;\n    }\n\n    public JSONArray add(double value) {\n        values.add(JSON.of(value));\n        return this;\n    }\n\n    public JSONArray add(JSONValue value) {\n        values.add(value);\n        return this;\n    }\n\n    public JSONArray addNull() {\n        values.add(JSON.of());\n        return this;\n    }\n\n    public JSONValue get(int i) {\n        return values.get(i);\n    }\n\n    public int size() {\n        return values.size();\n    }\n\n    public boolean isEmpty() {\n        return values.isEmpty();\n    }\n\n    @Override\n    public String toString() {\n        var builder = new StringBuilder();\n\n        builder.append(\"[\");\n        for (var i = 0; i < size(); i++) {\n            builder.append(get(i).toString());\n            if (i != (size() - 1)) {\n                builder.append(\",\");\n            }\n        }\n        builder.append(\"]\");\n        return builder.toString();\n    }\n\n    @Override\n    public Stream<JSONValue> stream() {\n        return values.stream();\n    }\n\n    @Override\n    public Iterator<JSONValue> iterator() {\n        return values.iterator();\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        JSONArray that = (JSONArray) o;\n        return values.equals(that.values);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(values);\n    }\n}\n"
  },
  {
    "path": "json/src/main/java/org/openjdk/skara/json/JSONBoolean.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.json;\n\nimport java.util.Objects;\n\npublic class JSONBoolean implements JSONValue {\n    boolean value;\n\n    public JSONBoolean(boolean value) {\n        this.value = value;\n    }\n\n    @Override\n    public boolean isBoolean() {\n        return true;\n    }\n\n    @Override\n    public boolean asBoolean() {\n        return value;\n    }\n\n    @Override\n    public String toString() {\n        return value ? \"true\" : \"false\";\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        JSONBoolean that = (JSONBoolean) o;\n        return value == that.value;\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(value);\n    }\n}\n"
  },
  {
    "path": "json/src/main/java/org/openjdk/skara/json/JSONDecimal.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.json;\n\nimport java.util.Objects;\n\npublic class JSONDecimal implements JSONValue {\n    double value;\n\n    public JSONDecimal(double value) {\n        this.value = value;\n    }\n\n    @Override\n    public boolean isDouble() {\n        return true;\n    }\n\n    @Override\n    public double asDouble() {\n        return value;\n    }\n\n    @Override\n    public String toString() {\n        return Double.toString(value);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        JSONDecimal that = (JSONDecimal) o;\n        return Double.compare(that.value, value) == 0;\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(value);\n    }\n}\n"
  },
  {
    "path": "json/src/main/java/org/openjdk/skara/json/JSONNull.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.json;\n\npublic class JSONNull implements JSONValue {\n    static JSONNull instance = new JSONNull();\n\n    private JSONNull() {\n    }\n\n    @Override\n    public boolean isNull() {\n        return true;\n    }\n\n    @Override\n    public String asString() {\n        return null;\n    }\n\n    @Override\n    public JSONArray asArray() {\n        return null;\n    }\n\n    @Override\n    public JSONObject asObject() {\n        return null;\n    }\n\n    @Override\n    public String toString() {\n        return \"null\";\n    }\n}\n"
  },
  {
    "path": "json/src/main/java/org/openjdk/skara/json/JSONNumber.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.json;\n\nimport java.util.Objects;\n\nclass JSONNumber implements JSONValue {\n    long value;\n\n    public JSONNumber(int value) {\n        this.value = (long) value;\n    }\n\n    public JSONNumber(long value) {\n        this.value = value;\n    }\n\n    @Override\n    public boolean isInt() {\n        return true;\n    }\n\n    @Override\n    public boolean isLong() {\n        return true;\n    }\n\n    @Override\n    public int asInt() {\n        return Math.toIntExact(value);\n    }\n\n    @Override\n    public long asLong() {\n        return value;\n    }\n\n    @Override\n    public String toString() {\n        return Long.toString(value);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        JSONNumber that = (JSONNumber) o;\n        return value == that.value;\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(value);\n    }\n}\n"
  },
  {
    "path": "json/src/main/java/org/openjdk/skara/json/JSONObject.java",
    "content": "/*\n * Copyright (c) 2018, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.json;\n\nimport java.util.*;\nimport java.util.stream.Collectors;\n\npublic class JSONObject implements JSONValue {\n    public static class Field {\n        private final String name;\n        private final JSONValue value;\n\n        private Field(String name, JSONValue value) {\n            this.name = name;\n            this.value = value;\n        }\n\n        public String name() {\n            return name;\n        }\n\n        public JSONValue value() {\n            return value;\n        }\n    }\n\n    private final Map<String, JSONValue> value;\n\n    public JSONObject() {\n        this.value = new HashMap<>();\n    }\n\n    public JSONObject(Map<String, JSONValue> map) {\n        this.value = new HashMap<>(map);\n    }\n\n    @Override\n    public boolean isObject() {\n        return true;\n    }\n\n    @Override\n    public JSONObject asObject() {\n        return this;\n    }\n\n    public JSONObject put(String k, boolean v) {\n        value.put(k, JSON.of(v));\n        return this;\n    }\n\n    public JSONObject put(String k, int v) {\n        value.put(k, JSON.of(v));\n        return this;\n    }\n\n    public JSONObject put(String k, long v) {\n        value.put(k, JSON.of(v));\n        return this;\n    }\n\n    public JSONObject put(String k, String v) {\n        value.put(k, JSON.of(v));\n        return this;\n    }\n\n    public JSONObject put(String k, double v) {\n        value.put(k, JSON.of(v));\n        return this;\n    }\n\n    public JSONObject put(String k, JSONArray v) {\n        value.put(k, v);\n        return this;\n    }\n\n    public JSONObject put(String k, JSONObject v) {\n        value.put(k, v);\n        return this;\n    }\n\n    public JSONObject put(String k, JSONValue v) {\n        value.put(k, v);\n        return this;\n    }\n\n    public JSONObject putNull(String k) {\n        value.put(k, JSON.of());\n        return this;\n    }\n\n    public JSONObject remove(String k) {\n        value.remove(k);\n        return this;\n    }\n\n    public JSONValue get(String k) {\n        return value.get(k);\n    }\n\n    public JSONValue getOrDefault(String k, JSONValue fallback) {\n        return value.getOrDefault(k, fallback);\n    }\n\n    public List<Field> fields() {\n        return value.entrySet()\n                    .stream()\n                    .map(e -> new Field(e.getKey(), e.getValue()))\n                    .collect(Collectors.toList());\n    }\n\n    public boolean contains(String field) {\n        return value.containsKey(field);\n    }\n\n    @Override\n    public String toString() {\n        var builder = new StringBuilder();\n        builder.append(\"{\");\n        for (var key : value.keySet()) {\n            builder.append(\"\\\"\");\n            builder.append(key);\n            builder.append(\"\\\":\");\n            builder.append(value.get(key).toString());\n            builder.append(\",\");\n        }\n\n        var end = builder.length() - 1;\n        if (builder.charAt(end) == ',') {\n            builder.deleteCharAt(end);\n        }\n\n        builder.append(\"}\");\n        return builder.toString();\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        JSONObject that = (JSONObject) o;\n        return value.equals(that.value);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(value);\n    }\n}\n"
  },
  {
    "path": "json/src/main/java/org/openjdk/skara/json/JSONParser.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.json;\n\nimport java.util.*;\n\nclass JSONParser {\n    private int pos = 0;\n    private String input;\n    private final boolean allowComments;\n    private final boolean allowTrailingCommas;\n\n    JSONParser() {\n        this(false, false);\n    }\n\n    JSONParser(boolean allowComments, boolean allowTrailingCommas) {\n        this.allowComments = allowComments;\n        this.allowTrailingCommas = allowTrailingCommas;\n    }\n\n    private IllegalStateException failure(String message) {\n        return new IllegalStateException(String.format(\"[%d]: %s : %s\", pos, message, input));\n    }\n\n    private char current() {\n        return input.charAt(pos);\n    }\n\n    private Optional<Character> next() {\n        var nextPos = pos + 1;\n        return nextPos < input.length() ?\n            Optional.of(input.charAt(nextPos)) : Optional.empty();\n    }\n\n    private void advance() {\n        pos++;\n    }\n\n    private boolean hasInput() {\n        return pos < input.length();\n    }\n\n    private void expectMoreInput(String message) {\n        if (!hasInput()) {\n            throw failure(message);\n        }\n    }\n\n    private char next(String message) {\n        advance();\n        if (!hasInput()) {\n            throw failure(message);\n        }\n        return current();\n    }\n\n\n    private void expect(char c) {\n        var msg = String.format(\"Expected character %c\", c);\n\n        var n = next(msg);\n        if (n != c) {\n            throw failure(msg);\n        }\n    }\n\n    private void assume(char c, String message) {\n        expectMoreInput(message);\n        if (current() != c) {\n            throw failure(message);\n        }\n    }\n\n    private JSONBoolean parseBoolean() {\n        if (current() == 't') {\n            expect('r');\n            expect('u');\n            expect('e');\n            advance();\n            return new JSONBoolean(true);\n        }\n\n        if (current() == 'f') {\n            expect('a');\n            expect('l');\n            expect('s');\n            expect('e');\n            advance();\n            return new JSONBoolean(false);\n        }\n\n        throw failure(\"a boolean can only be 'true' or 'false'\");\n    }\n\n    private JSONValue parseNumber() {\n        var isInteger = true;\n        var builder = new StringBuilder();\n\n        if (current() == '-') {\n            builder.append(current());\n            advance();\n            expectMoreInput(\"a number cannot consist of only '-'\");\n        }\n\n        if (current() == '0') {\n            builder.append(current());\n            advance();\n\n            if (hasInput() && current() == '.') {\n                isInteger = false;\n                builder.append(current());\n                advance();\n\n                expectMoreInput(\"a number cannot end with '.'\");\n\n                if (!isDigit(current())) {\n                    throw failure(\"must be at least one digit after '.'\");\n                }\n\n                while (hasInput() && isDigit(current())) {\n                    builder.append(current());\n                    advance();\n                }\n            }\n        } else {\n            while (hasInput() && isDigit(current())) {\n                builder.append(current());\n                advance();\n            }\n\n            if (hasInput() && current() == '.') {\n                isInteger = false;\n                builder.append(current());\n                advance();\n\n                expectMoreInput(\"a number cannot end with '.'\");\n\n                if (!isDigit(current())) {\n                    throw failure(\"must be at least one digit after '.'\");\n                }\n\n                while (hasInput() && isDigit(current())) {\n                    builder.append(current());\n                    advance();\n                }\n            }\n        }\n\n        if (hasInput() && (current() == 'e' || current() == 'E')) {\n            isInteger = false;\n\n            builder.append(current());\n            advance();\n            expectMoreInput(\"a number cannot end with 'e' or 'E'\");\n\n            if (current() == '+' || current() == '-') {\n                builder.append(current());\n                advance();\n            }\n\n            if (!isDigit(current())) {\n                throw failure(\"a digit must follow {'e','E'}{'+','-'}\");\n            }\n\n            while (hasInput() && isDigit(current())) {\n                builder.append(current());\n                advance();\n            }\n        }\n\n        var value = builder.toString();\n        return isInteger ? new JSONNumber(Long.parseLong(value)) :\n                           new JSONDecimal(Double.parseDouble(value));\n\n    }\n\n    private JSONString parseString() {\n        var missingEndChar = \"string is not terminated with '\\\"'\";\n        var builder = new StringBuilder();\n        for (var c = next(missingEndChar); c != '\"'; c = next(missingEndChar)) {\n            if (c == '\\\\') {\n                var n = next(missingEndChar);\n                switch (n) {\n                    case '\"':\n                        builder.append(\"\\\"\");\n                        break;\n                    case '\\\\':\n                        builder.append(\"\\\\\");\n                        break;\n                    case '/':\n                        builder.append(\"/\");\n                        break;\n                    case 'b':\n                        builder.append(\"\\b\");\n                        break;\n                    case 'f':\n                        builder.append(\"\\f\");\n                        break;\n                    case 'n':\n                        builder.append(\"\\n\");\n                        break;\n                    case 'r':\n                        builder.append(\"\\r\");\n                        break;\n                    case 't':\n                        builder.append(\"\\t\");\n                        break;\n                    case 'u':\n                        var u1 = next(missingEndChar);\n                        var u2 = next(missingEndChar);\n                        var u3 = next(missingEndChar);\n                        var u4 = next(missingEndChar);\n                        var cp = Integer.parseInt(String.format(\"%c%c%c%c\", u1, u2, u3, u4), 16);\n                        builder.append(new String(new int[]{cp}, 0, 1));\n                        break;\n                    default:\n                        throw failure(String.format(\"Unexpected escaped character '%c'\", n));\n                }\n            } else {\n                builder.append(c);\n            }\n        }\n\n        advance(); // step beyond closing \"\n        return new JSONString(builder.toString());\n    }\n\n    private JSONArray parseArray() {\n        var error = \"array is not terminated with ']'\";\n        var list = new ArrayList<JSONValue>();\n\n        advance(); // step beyond opening '['\n        if (allowComments) {\n            consumeCommentsAndWhitespace();\n        } else {\n            consumeWhitespace();\n        }\n        expectMoreInput(error);\n\n        while (current() != ']') {\n            var val = parseValue();\n            list.add(val);\n\n            expectMoreInput(error);\n            if (current() == ',') {\n                advance();\n                if (allowTrailingCommas) {\n                    if (allowComments) {\n                        consumeCommentsAndWhitespace();\n                    } else {\n                        consumeWhitespace();\n                    }\n                }\n            }\n            expectMoreInput(error);\n        }\n\n        advance(); // step beyond closing ']'\n        return new JSONArray(list.toArray(new JSONValue[0]));\n    }\n\n    public JSONNull parseNull() {\n        expect('u');\n        expect('l');\n        expect('l');\n        advance();\n        return JSONNull.instance;\n    }\n\n    public JSONObject parseObject() {\n        var error = \"object is not terminated with '}'\";\n        var map = new HashMap<String, JSONValue>();\n\n        advance(); // step beyond opening '{'\n        if (allowComments) {\n            consumeCommentsAndWhitespace();\n        } else {\n            consumeWhitespace();\n        }\n        expectMoreInput(error);\n\n        while (current() != '}') {\n            var key = parseValue();\n            if (!(key instanceof JSONString)) {\n                throw failure(\"a field must of type string\");\n            }\n\n            if (!hasInput() || current() != ':') {\n                throw failure(\"a field must be followed by ':'\");\n            }\n            advance(); // skip ':'\n\n            var val = parseValue();\n            map.put(key.asString(), val);\n\n            expectMoreInput(error);\n            if (current() == ',') {\n                advance();\n                if (allowTrailingCommas) {\n                    if (allowComments) {\n                        consumeCommentsAndWhitespace();\n                    } else {\n                        consumeWhitespace();\n                    }\n                }\n            }\n            expectMoreInput(error);\n        }\n\n        advance(); // step beyond '}'\n        return new JSONObject(map);\n    }\n\n    private boolean isDigit(char c) {\n        return c == '0' ||\n               c == '1' ||\n               c == '2' ||\n               c == '3' ||\n               c == '4' ||\n               c == '5' ||\n               c == '6' ||\n               c == '7' ||\n               c == '8' ||\n               c == '9';\n    }\n\n    private boolean isStartOfNumber(char c) {\n        return isDigit(c) || c == '-';\n    }\n\n    private boolean isStartOfString(char c) {\n        return c == '\"';\n    }\n\n    private boolean isStartOfBoolean(char c) {\n        return c == 't' || c == 'f';\n    }\n\n    private boolean isStartOfArray(char c) {\n        return c == '[';\n    }\n\n    private boolean isStartOfNull(char c) {\n        return c == 'n';\n    }\n\n    private boolean isWhitespace(char c) {\n        return c == '\\r' ||\n               c == '\\n' ||\n               c == '\\t' ||\n               c == ' ';\n    }\n\n    private boolean isStartOfObject(char c) {\n        return c == '{';\n    }\n\n    private void consumeCommentsAndWhitespace() {\n        while (hasInput() && (isWhitespace(current()) || isComment())) {\n            consumeWhitespace();\n            consumeComment();\n        }\n    }\n\n    private boolean isComment() {\n        return hasInput() &&\n               current() == '/' &&\n               (next().equals(Optional.of('*')) || next().equals(Optional.of('/')));\n    }\n\n    private void consumeComment() {\n        if (isComment()) {\n            advance();\n            if (current() == '/') {\n                advance();\n                while (hasInput() && current() != '\\n') {\n                    advance();\n                }\n            } else {\n                advance();\n                while (hasInput()) {\n                    if (current() == '*' && next().equals(Optional.of('/'))) {\n                        advance();\n                        advance();\n                        break;\n                    }\n                    advance();\n                }\n            }\n        }\n    }\n\n    private void consumeWhitespace() {\n        while (hasInput() && isWhitespace(current())) {\n            advance();\n        }\n    }\n\n    public JSONValue parseValue() {\n        JSONValue ret = null;\n\n        if (allowComments) {\n            consumeCommentsAndWhitespace();\n        } else {\n            consumeWhitespace();\n        }\n        if (hasInput()) {\n            var c = current();\n\n            if (isStartOfNumber(c)) {\n                ret = parseNumber();\n            } else if (isStartOfString(c)) {\n                ret = parseString();\n            } else if (isStartOfBoolean(c)) {\n                ret = parseBoolean();\n            } else if (isStartOfArray(c)) {\n                ret = parseArray();\n            } else if (isStartOfNull(c)) {\n                ret = parseNull();\n            } else if (isStartOfObject(c)) {\n                ret = parseObject();\n            } else {\n                throw failure(\"not a valid start of a JSON value\");\n            }\n        }\n        if (allowComments) {\n            consumeCommentsAndWhitespace();\n        } else {\n            consumeWhitespace();\n        }\n\n        return ret;\n    }\n  \n    public JSONValue parse(String s) {\n        if (s == null || s.equals(\"\")) {\n            return null;\n        }\n\n        pos = 0;\n        input = s;\n\n        var result = parseValue();\n        if (hasInput()) {\n            throw failure(\"can only have one top-level JSON value\");\n        }\n        return result;\n    }\n}\n"
  },
  {
    "path": "json/src/main/java/org/openjdk/skara/json/JSONString.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.json;\n\nimport java.util.Objects;\n\nclass JSONString implements JSONValue {\n    String value;\n\n    public JSONString(String value) {\n        this.value = value;\n    }\n\n    @Override\n    public boolean isString() {\n        return true;\n    }\n\n    @Override\n    public String asString() {\n        return value;\n    }\n\n    @Override\n    public String toString() {\n        var builder = new StringBuilder();\n        builder.append(\"\\\"\");\n\n        for (var i = 0; i < value.length(); i++) {\n            var c = value.charAt(i);\n\n            switch (c) {\n                case '\"':\n                    builder.append(\"\\\\\\\"\");\n                    break;\n                case '\\\\':\n                    builder.append(\"\\\\\\\\\");\n                    break;\n                case '/':\n                    builder.append(\"\\\\/\");\n                    break;\n                case '\\b':\n                    builder.append(\"\\\\b\");\n                    break;\n                case '\\f':\n                    builder.append(\"\\\\f\");\n                    break;\n                case '\\n':\n                    builder.append(\"\\\\n\");\n                    break;\n                case '\\r':\n                    builder.append(\"\\\\r\");\n                    break;\n                case '\\t':\n                    builder.append(\"\\\\t\");\n                    break;\n                default:\n                    builder.append(c);\n                    break;\n            }\n        }\n\n        builder.append(\"\\\"\");\n        return builder.toString();\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        JSONString that = (JSONString) o;\n        return Objects.equals(value, that.value);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(value);\n    }\n}\n"
  },
  {
    "path": "json/src/main/java/org/openjdk/skara/json/JSONValue.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.json;\n\nimport java.util.stream.Stream;\nimport java.util.List;\n\npublic interface JSONValue {\n    default int asInt() {\n        throw new IllegalStateException(\"Unsupported conversion to int\");\n    }\n\n    default long asLong() {\n        throw new IllegalStateException(\"Unsupported conversion to long\");\n    }\n\n    default double asDouble() {\n        throw new IllegalStateException(\"Unsupported conversion to double\");\n    }\n\n    default String asString() {\n        throw new IllegalStateException(\"Unsupported conversion to String\");\n    }\n\n    default boolean asBoolean() {\n        throw new IllegalStateException(\"Unsupported conversion to boolean\");\n    }\n\n    default JSONArray asArray() {\n        throw new IllegalStateException(\"Unsupported conversion to array\");\n    }\n\n    default JSONObject asObject() {\n        throw new IllegalStateException(\"Unsupported conversion to object\");\n    }\n\n    default boolean isInt() {\n        return false;\n    }\n\n    default boolean isLong() {\n        return false;\n    }\n\n    default boolean isDouble() {\n        return false;\n    }\n\n    default boolean isString() {\n        return false;\n    }\n\n    default boolean isBoolean() {\n        return false;\n    }\n\n    default boolean isArray() {\n        return false;\n    }\n\n    default boolean isObject() {\n        return false;\n    }\n\n    default boolean isNull() {\n        return false;\n    }\n\n    default List<JSONObject.Field> fields() {\n        return asObject().fields();\n    }\n\n    default boolean contains(String field) {\n        return asObject().contains(field);\n    }\n\n    default JSONValue get(String field) {\n        return asObject().get(field);\n    }\n\n    default JSONValue getOrDefault(String field, JSONValue fallback) {\n        return asObject().getOrDefault(field, fallback);\n    }\n\n    default JSONValue get(int i) {\n        return asArray().get(i);\n    }\n\n    default Stream<JSONValue> stream() {\n        return Stream.of(this);\n    }\n\n    static JSONValue from(int i) {\n        return new JSONNumber(i);\n    }\n\n    static JSONValue from(long l) {\n        return new JSONNumber(l);\n    }\n\n    static JSONValue from(double d) {\n        return new JSONDecimal(d);\n    }\n\n    static JSONValue from(boolean b) {\n        return new JSONBoolean(b);\n    }\n\n    static JSONValue from(String s) {\n        return new JSONString(s);\n    }\n\n    static JSONValue fromNull() {\n        return JSONNull.instance;\n    }\n}\n"
  },
  {
    "path": "json/src/main/java/org/openjdk/skara/json/JWCC.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.json;\n\n/**\n * JWCC is JSON With Commas and Comments. In addition to supporting all of JSON\n * JWCC also supports trailing commas and comments. Comments can be either\n * until single-line or multi-line.\n *\n * Comments are stripped and are not present in the parsed result.\n */\npublic class JWCC {\n    public static JSONValue parse(String s) {\n        return new JSONParser(true, true).parse(s);\n    }\n}\n"
  },
  {
    "path": "json/src/test/java/org/openjdk/skara/json/JSONParserTests.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.json;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.util.*;\nimport java.util.stream.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class JSONParserTests {\n    private final JSONParser parser = new JSONParser();\n\n    @Test\n    void testParseTrue() {\n        var value = parser.parse(\"true\");\n        assertEquals(value.asBoolean(), true);\n    }\n\n    @Test\n    void testParseFalse() {\n        var value = parser.parse(\"false\");\n        assertEquals(value.asBoolean(), false);\n    }\n\n    @Test\n    void testParseInt() {\n        var value = parser.parse(\"17\");\n        assertEquals(value.asInt(), 17);\n    }\n\n    @Test\n    void testParseTrueWithWhitespace() {\n        var value = parser.parse(\"    true   \\n \\t   \\r\\n\");\n        assertEquals(value.asBoolean(), true);\n    }\n\n    @Test\n    void testParseFalseWithWhitespace() {\n        var value = parser.parse(\"\\r\\n    \\t    false  \\t\\t\\t\");\n        assertEquals(value.asBoolean(), false);\n    }\n\n    @Test\n    void testParseString() {\n        var value = parser.parse(\"\\\"Hello, JSON\\\"\");\n        assertEquals(value.asString(), \"Hello, JSON\");\n    }\n\n    @Test\n    void testParseArray() {\n        var value = parser.parse(\"[1,2,3]\");\n        assertEquals(value.asArray().get(0).asInt(), 1);\n        assertEquals(value.asArray().get(1).asInt(), 2);\n        assertEquals(value.asArray().get(2).asInt(), 3);\n    }\n\n    @Test\n    void testParseNull() {\n        var value = parser.parse(\"null\");\n        assertEquals(value.asArray(), null);\n    }\n\n    @Test\n    void testParseObject() {\n        var value = parser.parse(\"{\\\"a\\\":1,\\\"b\\\":2,\\\"c\\\":3}\");\n        assertEquals(value.asObject().get(\"a\").asInt(), 1);\n        assertEquals(value.asObject().get(\"b\").asInt(), 2);\n        assertEquals(value.asObject().get(\"c\").asInt(), 3);\n    }\n\n    @Test\n    void testParseArrayWithWhitespace() {\n        var value = parser.parse(\"\\n\\n\\n\\t [  1, \\t\\n   2, \\r\\n \\t \\t 3  ] \\t\\t\\t\\n\");\n        assertEquals(value.asArray().get(0).asInt(), 1);\n        assertEquals(value.asArray().get(1).asInt(), 2);\n        assertEquals(value.asArray().get(2).asInt(), 3);\n    }\n\n    @Test\n    void testParseObjectWithWhitespace() {\n        var value = parser.parse(\"   \\t \\r\\n   {  \\t \\n \\r\\\"a\\\" \\n\\n \\t : \\r\\n 1 \\n\\n, \\\"b\\\"  :  2 ,  \\\"c\\\"  :  3  }  \");\n        assertEquals(value.asObject().get(\"a\").asInt(), 1);\n        assertEquals(value.asObject().get(\"b\").asInt(), 2);\n        assertEquals(value.asObject().get(\"c\").asInt(), 3);\n    }\n\n    @Test\n    void testObjectShortcut() {\n        var value = parser.parse(\"{ \\\"a\\\":1, \\\"b\\\":2, \\\"c\\\":3 }\");\n        assertEquals(value.get(\"a\").asInt(), 1);\n        assertEquals(value.get(\"b\").asInt(), 2);\n        assertEquals(value.get(\"c\").asInt(), 3);\n    }\n\n    @Test\n    void testArrayShortcut() {\n        var value = parser.parse(\"[ 1, 2, 3]\");\n        assertEquals(value.get(0).asInt(), 1);\n        assertEquals(value.get(1).asInt(), 2);\n        assertEquals(value.get(2).asInt(), 3);\n    }\n\n    @Test\n    void testIntToString() {\n        var v = JSON.of(17);\n        assertEquals(v.toString(), \"17\");\n    }\n\n    @Test\n    void testDoubleToString() {\n        var v = JSON.of(17.7);\n        assertEquals(v.toString(), \"17.7\");\n    }\n\n    @Test\n    void testBooleanToString() {\n        var v = JSON.of(true);\n        assertEquals(v.toString(), \"true\");\n    }\n\n    @Test\n    void testNullToString() {\n        var v = JSON.of();\n        assertEquals(v.toString(), \"null\");\n    }\n\n    @Test\n    void testStringToString() {\n        var v = JSON.of(\"Hello, JSON\");\n        assertEquals(v.toString(), \"\\\"Hello, JSON\\\"\");\n    }\n\n    @Test\n    void testArrayToString() {\n        var v = new JSONArray();\n        v.add(1);\n        v.add(2);\n        v.add(3);\n        assertEquals(v.toString(), \"[1,2,3]\");\n    }\n\n    @Test\n    void testObjectToString() {\n        var v = new JSONObject();\n        v.put(\"a\", 1);\n        v.put(\"b\", 2);\n        v.put(\"c\", 3);\n        assertEquals(v.toString(), \"{\\\"a\\\":1,\\\"b\\\":2,\\\"c\\\":3}\");\n    }\n\n    @Test\n    void testNestedObjectToString() {\n        var inner = new JSONObject();\n        inner.put(\"a\", 1);\n        inner.put(\"b\", 2);\n        inner.put(\"c\", 3);\n\n        var outer = new JSONObject();\n        outer.put(\"inner\", inner);\n        assertEquals(outer.toString(), \"{\\\"inner\\\":{\\\"a\\\":1,\\\"b\\\":2,\\\"c\\\":3}}\");\n    }\n\n    @Test\n    void testToStringAndParse() {\n        var inner = new JSONObject();\n        inner.put(\"a\", 1);\n        inner.put(\"b\", 2);\n        inner.put(\"c\", 3);\n\n        var outer = new JSONObject();\n        outer.put(\"inner\", inner);\n\n        var s = outer.toString();\n\n        var parsed = parser.parse(s);\n        assertEquals(parsed.get(\"inner\").get(\"a\").asInt(), 1);\n        assertEquals(parsed.get(\"inner\").get(\"b\").asInt(), 2);\n        assertEquals(parsed.get(\"inner\").get(\"c\").asInt(), 3);\n    }\n\n    @Test\n    void testLargerJSONText() {\n        var text = \"{\\n\" +\n                   \"  \\\"name\\\": \\\"mighty_readme\\\",\\n\" +\n                   \"  \\\"head_sha\\\": \\\"ce587453ced02b1526dfb4cb910479d431683101\\\",\\n\" +\n                   \"  \\\"status\\\": \\\"completed\\\",\\n\" +\n                   \"  \\\"started_at\\\": \\\"2017-11-30T19:39:10Z\\\",\\n\" +\n                   \"  \\\"completed_at\\\": \\\"2017-11-30T19:49:10Z\\\",\\n\" +\n                   \"  \\\"output\\\": {\\n\" +\n                   \"    \\\"title\\\": \\\"Mighty Readme report\\\",\\n\" +\n                   \"    \\\"summary\\\": \\\"There are 0 failures, 2 warnings, and 1 notices.\\\",\\n\" +\n                   \"    \\\"text\\\": \\\"You may have some misspelled words on lines 2 and 4. You also may want to add a section in your README about how to install your app.\\\",\\n\" +\n                   \"    \\\"annotations\\\": [\\n\" +\n                   \"      {\\n\" +\n                   \"        \\\"path\\\": \\\"README.md\\\",\\n\" +\n                   \"        \\\"annotation_level\\\": \\\"warning\\\",\\n\" +\n                   \"        \\\"title\\\": \\\"Spell Checker\\\",\\n\" +\n                   \"        \\\"message\\\": \\\"Check your spelling for 'banaas'.\\\",\\n\" +\n                   \"        \\\"raw_details\\\": \\\"Do you mean 'bananas' or 'banana'?\\\",\\n\" +\n                   \"        \\\"start_line\\\": \\\"2\\\",\\n\" +\n                   \"        \\\"end_line\\\": \\\"2\\\"\\n\" +\n                   \"      },\\n\" +\n                   \"      {\\n\" +\n                   \"        \\\"path\\\": \\\"README.md\\\",\\n\" +\n                   \"        \\\"annotation_level\\\": \\\"warning\\\",\\n\" +\n                   \"        \\\"title\\\": \\\"Spell Checker\\\",\\n\" +\n                   \"        \\\"message\\\": \\\"Check your spelling for 'aples'\\\",\\n\" +\n                   \"        \\\"raw_details\\\": \\\"Do you mean 'apples' or 'Naples'\\\",\\n\" +\n                   \"        \\\"start_line\\\": \\\"4\\\",\\n\" +\n                   \"        \\\"end_line\\\": \\\"4\\\"\\n\" +\n                   \"      }\\n\" +\n                   \"    ],\\n\" +\n                   \"    \\\"images\\\": [\\n\" +\n                   \"      {\\n\" +\n                   \"        \\\"alt\\\": \\\"Super bananas\\\",\\n\" +\n                   \"        \\\"image_url\\\": \\\"http://example.com/images/42\\\"\\n\" +\n                   \"      }\\n\" +\n                   \"    ]\\n\" +\n                   \"  },\\n\" +\n                   \"  \\\"actions\\\": [\\n\" +\n                   \"    {\\n\" +\n                   \"      \\\"label\\\": \\\"Fix\\\",\\n\" +\n                   \"      \\\"identifier\\\": \\\"fix_errors\\\",\\n\" +\n                   \"      \\\"description\\\": \\\"Allow us to fix these errors for you\\\"\\n\" +\n                   \"    }\\n\" +\n                   \"  ]\\n\" +\n                   \"}\";\n        var v = parser.parse(text);\n        assertEquals(v.get(\"name\").asString(), \"mighty_readme\");\n        assertEquals(v.get(\"output\").get(\"annotations\").get(0).get(\"path\").asString(), \"README.md\");\n    }\n\n    @Test\n    void testAPI() {\n        var o = JSON.object()\n                    .put(\"a\", 1)\n                    .put(\"b\", 2)\n                    .put(\"c\", 3);\n\n        var parsed = JSON.parse(o.toString());\n        assertEquals(parsed.get(\"a\").asInt(), 1);\n        assertEquals(parsed.get(\"b\").asInt(), 2);\n        assertEquals(parsed.get(\"c\").asInt(), 3);\n\n        var a = JSON.array()\n                    .add(\"a\")\n                    .add(2)\n                    .add(false)\n                    .add(3.14);\n\n        parsed = JSON.parse(a.toString());\n        assertEquals(parsed.get(0).asString(), \"a\");\n        assertEquals(parsed.get(1).asInt(), 2);\n        assertEquals(parsed.get(2).asBoolean(), false);\n        assertEquals(parsed.get(3).asDouble(), 3.14);\n\n        var o2 = JSON.object()\n                     .put(\"inner\",\n                        JSON.object()\n                            .put(\"x\", 1)\n                            .put(\"y\", \"user_2\")\n                            .put(\"z\", 2.1))\n                     .put(\"array\",\n                        JSON.array()\n                            .add(4)\n                            .add(false)\n                            .add(\"user_1\"));\n\n        parsed = JSON.parse(o2.toString());\n        assertEquals(parsed.get(\"inner\").get(\"x\").asInt(), 1);\n        assertEquals(parsed.get(\"inner\").get(\"y\").asString(), \"user_2\");\n        assertEquals(parsed.get(\"inner\").get(\"z\").asDouble(), 2.1);\n        assertEquals(parsed.get(\"array\").get(0).asInt(), 4);\n        assertEquals(parsed.get(\"array\").get(1).asBoolean(), false);\n        assertEquals(parsed.get(\"array\").get(2).asString(), \"user_1\");\n    }\n\n    @Test\n    void testParseStringWithCitation() {\n        var v = JSON.parse(\"\\\"hello, \\\\\\\"citation\\\\\\\"\\\"\");\n        assertEquals(\"hello, \\\"citation\\\"\", v.asString());\n    }\n\n    @Test\n    void testParseStringBackslash() {\n        var v = JSON.parse(\"\\\"hello, backslash: \\\\\\\\ \\\"\");\n        assertEquals(\"hello, backslash: \\\\ \", v.asString());\n    }\n\n    @Test\n    void testParseStringBackslashAndN() {\n        var v = JSON.parse(\"\\\"hello, backslash: \\\\\\\\n \\\"\");\n        assertEquals(\"hello, backslash: \\\\n \", v.asString());\n    }\n\n    @Test\n    void testParseEmptyString() {\n        var v = JSON.parse(\"\\\"\\\"\");\n        assertEquals(\"\", v.asString());\n    }\n\n    @Test\n    void testParseStringWithNewline() {\n        var v = JSON.parse(\"\\\"hello newline\\\\n\\\"\");\n        assertEquals(\"hello newline\\n\", v.asString());\n    }\n\n    @Test\n    void testStreamAPI() {\n        var v = JSON.array().add(1).add(2).add(3);\n        var a = v.stream().mapToInt(JSONValue::asInt).toArray();\n        assertEquals(a[0], 1);\n        assertEquals(a[1], 2);\n        assertEquals(a[2], 3);\n\n        var v2 = JSON.of(17.7);\n        assertEquals(v2.stream().count(), 1L);\n    }\n\n    @Test\n    void testIterateFieldsInObject() {\n        var o = JSON.object()\n                    .put(\"a\", 1)\n                    .put(\"b\", 2)\n                    .put(\"c\", 3);\n\n        var fields = o.fields();\n        assertEquals(fields.size(), 3);\n\n        var seen = new HashSet<String>();\n        fields.forEach(f -> seen.add(f.name()));\n        assertTrue(seen.contains(\"a\"));\n        assertTrue(seen.contains(\"b\"));\n        assertTrue(seen.contains(\"c\"));\n    }\n\n    @Test\n    void testObjectContains() {\n        var o = JSON.object().put(\"a\", 1);\n        assertTrue(o.contains(\"a\"));\n        assertFalse(o.contains(\"b\"));\n    }\n\n    @Test\n    void testArrayIterator() {\n        var array = JSON.array().add(1).add(2).add(3);\n        var count = 0;\n        for (var e : array) {\n            count++;\n        }\n        assertEquals(count, 3);\n    }\n\n    @Test\n    void testStringEncodingWithEscapedChars() {\n        var s = JSON.of(\"hello newline\\n\");\n        assertEquals(\"\\\"hello newline\\\\n\\\"\", s.toString());\n\n        s = JSON.of(\"backslash: \\\\\");\n        assertEquals(\"\\\"backslash: \\\\\\\\\\\"\", s.toString());\n    }\n\n    @Test\n    void testLongNumber() {\n        var l = 1337L;\n        var json = JSON.of(l);\n        assertEquals(\"1337\", json.toString());\n        assertEquals(1337L, json.asLong());\n        assertEquals(1337, json.asInt());\n    }\n\n    @Test\n    void testEscapedUnicodeCodePoint() {\n        var s = \"\\\"\\\\ud83d\\\\ude04\\\"\";\n        var json = JSON.parse(s);\n        assertEquals(\"\\ud83d\\ude04\", json.asString());\n\n        s = \"\\\"\\\\u003c\\\"\";\n        json = JSON.parse(s);\n        assertEquals(\"\\u003c\", json.asString());\n        assertEquals(\"<\", json.asString());\n    }\n\n    @Test\n    void testLargeGitLabExample() {\n        var s =\n        \"[\" +\n            \"{\" +\n                \"\\\"id\\\":369,\" +\n                \"\\\"iid\\\":2,\" +\n                \"\\\"project_id\\\":55,\" +\n                \"\\\"title\\\":\\\"Add some useful whitespace\\\",\"+\n                \"\\\"description\\\":\\\"It is that time.\\\\n\\\\n\\\\u003c!-- \" +\n                                  \"Anything below this marker will be \" +\n                                  \"automatically updated, please do not \" +\n                                  \"edit manually! --\\\\u003e\\\\n\\\\n- [x] \" +\n                                  \"Your change must have been available for \" +\n                                  \"review at least 24 hours\\\\n- [ ] Title must \" +\n                                  \"be of the format id: description where id \" +\n                                  \"matches an existing JBS issue\\\",\" +\n                \"\\\"state\\\":\\\"opened\\\",\" +\n                \"\\\"created_at\\\":\\\"2018-09-06T11:52:39.314Z\\\",\" +\n                \"\\\"updated_at\\\":\\\"2018-09-10T13:08:27.648Z\\\",\" +\n                \"\\\"target_branch\\\":\\\"master\\\",\" +\n                \"\\\"source_branch\\\":\\\"rwtest-1\\\",\" +\n                \"\\\"upvotes\\\":0,\" +\n                \"\\\"downvotes\\\":0,\"+\n                \"\\\"author\\\":{\" +\n                    \"\\\"id\\\":2,\" +\n                    \"\\\"name\\\":\\\"User Number 3\\\",\" +\n                    \"\\\"username\\\":\\\"user_3\\\",\" +\n                    \"\\\"state\\\":\\\"active\\\",\" +\n                    \"\\\"avatar_url\\\":\\\"avatar.png\\\",\" +\n                    \"\\\"web_url\\\":\\\"https://host.com/user_3\\\"\" +\n                    \"},\" +\n                \"\\\"assignee\\\":null,\" +\n                \"\\\"source_project_id\\\":55,\" +\n                \"\\\"target_project_id\\\":55,\" +\n                \"\\\"labels\\\":[],\" +\n                \"\\\"draft\\\":false,\" +\n                \"\\\"milestone\\\":null,\" +\n                \"\\\"merge_when_pipeline_succeeds\\\":false,\" +\n                \"\\\"merge_status\\\":\\\"can_be_merged\\\",\" +\n                \"\\\"sha\\\":\\\"e282f1d56fa0710783d1c5d77a6c850669937a72\\\",\" +\n                \"\\\"merge_commit_sha\\\":null,\" +\n                \"\\\"user_notes_count\\\":2,\" +\n                \"\\\"discussion_locked\\\":null,\" +\n                \"\\\"should_remove_source_branch\\\":null,\" +\n                \"\\\"force_remove_source_branch\\\":false,\" +\n                \"\\\"web_url\\\":\\\"https://host.com/user_3/test/merge_requests/2\\\",\" +\n                \"\\\"time_stats\\\":{\" +\n                    \"\\\"time_estimate\\\":0,\" +\n                    \"\\\"total_time_spent\\\":0,\" +\n                    \"\\\"human_time_estimate\\\":null,\" +\n                    \"\\\"human_total_time_spent\\\":null\" +\n                    \"},\" +\n                \"\\\"squash\\\":false\" +\n            \"}\" +\n        \"]\";\n\n        var json = JSON.parse(s);\n        assertEquals(369, json.get(0).get(\"id\").asInt());\n        assertEquals(\"active\", json.get(0).get(\"author\").get(\"state\").asString());\n    }\n\n    @Test\n    public void testIsNull() {\n        var json = JSON.parse(\"[{\\\"id\\\":705,\\\"type\\\":null,\\\"body\\\":\\\"description\\\"}]\");\n        assertTrue(json.get(0).get(\"type\").isNull());\n        assertFalse(json.get(0).get(\"type\").isInt());\n        assertFalse(json.get(0).get(\"type\").isLong());\n        assertFalse(json.get(0).get(\"type\").isDouble());\n        assertFalse(json.get(0).get(\"type\").isString());\n        assertFalse(json.get(0).get(\"type\").isBoolean());\n        assertFalse(json.get(0).get(\"type\").isArray());\n        assertFalse(json.get(0).get(\"type\").isObject());\n\n        assertFalse(json.get(0).get(\"id\").isNull());\n    }\n\n    @Test\n    public void testContainsShortcut() {\n        var json = JSON.parse(\"{\\\"id\\\":705,\\\"type\\\":null,\\\"body\\\":\\\"description\\\"}\");\n        assertTrue(json.contains(\"id\"));\n        assertFalse(json.contains(\"header\"));\n        assertTrue(json.contains(\"type\"));\n    }\n\n    @Test\n    public void testFieldsShortcut() {\n        var json = JSON.parse(\"{\\\"id\\\":705,\\\"type\\\":null,\\\"body\\\":\\\"description\\\"}\");\n        var names = json.fields().stream().map(JSONObject.Field::name).collect(Collectors.toSet());\n        assertEquals(Set.of(\"id\", \"type\", \"body\"), names);\n    }\n\n    @Test\n    public void testArrayWithWhitespace() {\n        var json = JSON.parse(\"{ \\\"foo\\\": [ ] }\");\n        assertEquals(0, json.get(\"foo\").asArray().size());\n    }\n\n    @Test\n    public void testObjectWithWhitespace() {\n        var json = JSON.parse(\"{ \\\"foo\\\": { } }\");\n        assertEquals(0, json.get(\"foo\").asObject().fields().size());\n    }\n\n    @Test\n    public void testIsInt() {\n        var json = JSON.parse(\"{ \\\"foo\\\": 1 }\");\n\n        assertTrue(json.get(\"foo\").isInt());\n        assertTrue(json.get(\"foo\").isLong());\n\n        assertFalse(json.get(\"foo\").isDouble());\n        assertFalse(json.get(\"foo\").isString());\n        assertFalse(json.get(\"foo\").isBoolean());\n        assertFalse(json.get(\"foo\").isArray());\n        assertFalse(json.get(\"foo\").isObject());\n        assertFalse(json.get(\"foo\").isNull());\n    }\n\n    @Test\n    public void testIsLong() {\n        var json = JSON.parse(\"{ \\\"foo\\\": 1337 }\");\n\n        assertTrue(json.get(\"foo\").isInt());\n        assertTrue(json.get(\"foo\").isLong());\n\n        assertFalse(json.get(\"foo\").isDouble());\n        assertFalse(json.get(\"foo\").isString());\n        assertFalse(json.get(\"foo\").isBoolean());\n        assertFalse(json.get(\"foo\").isArray());\n        assertFalse(json.get(\"foo\").isObject());\n        assertFalse(json.get(\"foo\").isNull());\n    }\n\n    @Test\n    public void testIsDouble() {\n        var json = JSON.parse(\"{ \\\"foo\\\": 17.7 }\");\n\n        assertTrue(json.get(\"foo\").isDouble());\n\n        assertFalse(json.get(\"foo\").isInt());\n        assertFalse(json.get(\"foo\").isLong());\n        assertFalse(json.get(\"foo\").isBoolean());\n        assertFalse(json.get(\"foo\").isString());\n        assertFalse(json.get(\"foo\").isArray());\n        assertFalse(json.get(\"foo\").isObject());\n        assertFalse(json.get(\"foo\").isNull());\n    }\n\n    @Test\n    public void testIsString() {\n        var json = JSON.parse(\"{ \\\"foo\\\": \\\"bar\\\" }\");\n\n        assertTrue(json.get(\"foo\").isString());\n\n        assertFalse(json.get(\"foo\").isInt());\n        assertFalse(json.get(\"foo\").isLong());\n        assertFalse(json.get(\"foo\").isDouble());\n        assertFalse(json.get(\"foo\").isBoolean());\n        assertFalse(json.get(\"foo\").isArray());\n        assertFalse(json.get(\"foo\").isObject());\n        assertFalse(json.get(\"foo\").isNull());\n    }\n\n    @Test\n    public void testIsBoolean() {\n        var json = JSON.parse(\"{ \\\"foo\\\": true }\");\n\n        assertTrue(json.get(\"foo\").isBoolean());\n\n        assertFalse(json.get(\"foo\").isInt());\n        assertFalse(json.get(\"foo\").isLong());\n        assertFalse(json.get(\"foo\").isDouble());\n        assertFalse(json.get(\"foo\").isString());\n        assertFalse(json.get(\"foo\").isArray());\n        assertFalse(json.get(\"foo\").isObject());\n        assertFalse(json.get(\"foo\").isNull());\n    }\n\n    @Test\n    public void testIsArray() {\n        var json = JSON.parse(\"{ \\\"foo\\\": [1,2,3] }\");\n\n        assertTrue(json.get(\"foo\").isArray());\n\n        assertFalse(json.get(\"foo\").isInt());\n        assertFalse(json.get(\"foo\").isLong());\n        assertFalse(json.get(\"foo\").isDouble());\n        assertFalse(json.get(\"foo\").isBoolean());\n        assertFalse(json.get(\"foo\").isString());\n        assertFalse(json.get(\"foo\").isObject());\n        assertFalse(json.get(\"foo\").isNull());\n    }\n\n    @Test\n    public void testIsObject() {\n        var json = JSON.parse(\"{ \\\"foo\\\": { \\\"bar\\\": true } }\");\n\n        assertTrue(json.get(\"foo\").isObject());\n\n        assertFalse(json.get(\"foo\").isInt());\n        assertFalse(json.get(\"foo\").isLong());\n        assertFalse(json.get(\"foo\").isDouble());\n        assertFalse(json.get(\"foo\").isBoolean());\n        assertFalse(json.get(\"foo\").isString());\n        assertFalse(json.get(\"foo\").isArray());\n        assertFalse(json.get(\"foo\").isNull());\n    }\n\n    @Test\n    public void testJSONObjectWithNullField() {\n        var json = JSON.parse(\"{ \\\"foo\\\": null }\");\n\n        assertNotNull(json.get(\"foo\"));\n        assertTrue(json.get(\"foo\").isNull());\n    }\n}\n"
  },
  {
    "path": "json/src/test/java/org/openjdk/skara/json/JWCCTests.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.json;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.util.*;\nimport java.util.stream.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class JWCCTests {\n    @Test\n    public void testSingleLineComment() {\n        var text = \"\"\"\n                   // this is a comment before the object\n                   { // this is a comment after opening brace\n                     \"foo\": \"bar\" // this is a comment after field\n                   } // this is a comment after closening brace\n                   // this is a comment after the object\n                   \"\"\";\n        var json = JWCC.parse(text);\n        assertEquals(\"bar\", json.get(\"foo\").asString());\n    }\n\n    @Test\n    public void testSingleLineCommentAfterKey() {\n        var text = \"\"\"\n                   {\n                     \"foo\": // comment\n                         \"bar\"\n                   }\n                   \"\"\";\n        var json = JWCC.parse(text);\n        assertEquals(\"bar\", json.get(\"foo\").asString());\n    }\n\n    @Test\n    public void testSingleLineCommentAfterKeyWithoutValue() {\n        var text = \"\"\"\n                   {\n                     \"foo\": // comment\n                   }\n                   \"\"\";\n        assertThrows(IllegalStateException.class, () -> {\n            JWCC.parse(text);\n        });\n    }\n\n    @Test\n    public void testInlineComment() {\n        var text = \"\"\"\n                   /*\n                    * This is a multi-line comment\n                    *\n                    */\n                   /*\n                    * This is another multi-line comment with JSON in it\n                    {\n                      \"foo\": 17\n                    }\n                    */\n                   /* small comment */ { /* another\n                   multi-line */\n                     /* before */ \"foo\" /* a comment */ : /* another comment */ \"bar\" /* so many comments */\n                   } /* after */\n                   /*\n                    * A final multi-line\n                    */\n                   \"\"\";\n        var json = JWCC.parse(text);\n        assertEquals(\"bar\", json.get(\"foo\").asString());\n    }\n\n    @Test\n    public void testInlineAndLineComment() {\n        var text = \"\"\"\n                   /*\n                    * This is a multi-line comment\n                    * // with a line comment inside it\n                    */\n                   /*\n                    * This is another multi-line comment with JSON in it\n                    {\n                      \"foo\": 17\n                    }\n                    */\n                   /* small comment */ { // until end-of-line with closing brace }\n                     /* before */ \"foo\" /* a comment */ : /* another comment */ \"bar\" /////// end-of-line\n                   } /* after */ // end-of-line /* with in-line */\n                   /*\n                    * A final multi-line\n                    */\n                    // A final singe-line\n                   \"\"\";\n        var json = JWCC.parse(text);\n        assertEquals(\"bar\", json.get(\"foo\").asString());\n    }\n\n    @Test\n    public void testInlineAndLineCommentWithArray() {\n        var text = \"\"\"\n                   /*\n                    * This is a multi-line comment\n                    * // with a line comment inside it\n                    */\n                   /*\n                    * This is another multi-line comment with JSON in it\n                    {\n                      \"foo\": 17\n                    }\n                    */\n                   /* small comment */ [ // until end-of-line with closing brace }\n                     /* before */ \"foo\" /* a comment */ , /* another comment */ \"bar\" /////// end-of-line\n                   ] /* after */ // end-of-line /* with in-line */\n                   /*\n                    * A final multi-line\n                    */\n                    // A final singe-line\n                   \"\"\";\n        var json = JWCC.parse(text);\n        assertEquals(\"foo\", json.get(0).asString());\n        assertEquals(\"bar\", json.get(1).asString());\n        assertEquals(2, json.asArray().size());\n    }\n\n    @Test\n    public void testTrailingComma() {\n        var text = \"\"\"\n                   {\n                       \"a\": 1,\n                       \"b\": 2,\n                   }\n                   \"\"\";\n        var json = JWCC.parse(text);\n        assertEquals(1, json.get(\"a\").asInt());\n        assertEquals(2, json.get(\"b\").asInt());\n    }\n\n    @Test\n    public void testTrailingCommaWithLineComment() {\n        var text = \"\"\"\n                   {\n                       \"a\": 1, // a comment\n                       \"b\": 2, // another comment\n                   }\n                   \"\"\";\n        var json = JWCC.parse(text);\n        assertEquals(1, json.get(\"a\").asInt());\n        assertEquals(2, json.get(\"b\").asInt());\n    }\n\n    @Test\n    public void testTrailingCommaWithInLineComment() {\n        var text = \"\"\"\n                   {\n                       \"a\": 1, /* an in-line */\n                       \"b\": 2, /* another in-line */\n                   }\n                   \"\"\";\n        var json = JWCC.parse(text);\n        assertEquals(1, json.get(\"a\").asInt());\n        assertEquals(2, json.get(\"b\").asInt());\n    }\n\n    @Test\n    public void testTrailingOnSameLine() {\n        var text = \"\"\"\n                   {\n                       \"a\": 1, \"b\": 2, /* in-line */ \"c\": 3,\n                   }\n                   \"\"\";\n        var json = JWCC.parse(text);\n        assertEquals(1, json.get(\"a\").asInt());\n        assertEquals(2, json.get(\"b\").asInt());\n        assertEquals(3, json.get(\"c\").asInt());\n    }\n\n    @Test\n    public void testTrailingWithArray() {\n        var text = \"\"\"\n                   [\n                       \"a\",\n                   ]\n                   \"\"\";\n        var json = JWCC.parse(text);\n        assertEquals(\"a\", json.get(0).asString());\n    }\n\n    @Test\n    public void testTrailingWithMultipleArray() {\n        var text = \"\"\"\n                   [\n                       \"a\",\n                       \"b\",\n                   ]\n                   \"\"\";\n        var json = JWCC.parse(text);\n        assertEquals(\"a\", json.get(0).asString());\n        assertEquals(\"b\", json.get(1).asString());\n    }\n}\n"
  },
  {
    "path": "mailinglist/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.mailinglist'\n    test {\n        requires 'org.openjdk.skara.test'\n        requires 'org.junit.jupiter.api'\n        opens 'org.openjdk.skara.mailinglist' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':network')\n    implementation project(':vcs')\n    implementation project(':email')\n    implementation project(':metrics')\n\n    testImplementation project(':test')\n}\n"
  },
  {
    "path": "mailinglist/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.mailinglist {\n    requires transitive org.openjdk.skara.metrics;\n    requires java.net.http;\n    requires java.logging;\n    requires org.openjdk.skara.network;\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.email;\n\n    exports org.openjdk.skara.mailinglist;\n}\n"
  },
  {
    "path": "mailinglist/src/main/java/org/openjdk/skara/mailinglist/Conversation.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist;\n\nimport org.openjdk.skara.email.*;\n\nimport java.util.*;\nimport java.util.function.Function;\nimport java.util.stream.*;\n\npublic class Conversation {\n    private final Email first;\n    private final Map<EmailAddress, LinkedHashSet<Email>> replies = new LinkedHashMap<>();\n    private final Map<EmailAddress, Email> parents = new HashMap<>();\n\n    Conversation(Email first) {\n        this.first = first;\n        replies.put(first.id(), new LinkedHashSet<>());\n    }\n\n    void addReply(Email parent, Email reply) {\n        if (!replies.containsKey(reply.id())) {\n            var replyList = replies.get(parent.id());\n            replyList.add(reply);\n            replies.put(reply.id(), new LinkedHashSet<>());\n        }\n        if (!parents.containsKey(reply.id())) {\n            parents.put(reply.id(), parent);\n        } else {\n            var oldParent = parents.get(reply.id());\n            if (!parent.equals(oldParent)) {\n                throw new RuntimeException(\"Email with id \" + reply.id() + \" seen with multiple parents: \" + oldParent.id() + \" and \" + parent.id());\n            }\n        }\n    }\n\n    public Email first() {\n        return first;\n    }\n\n    public List<Email> replies(Email parent) {\n        return new ArrayList<>(replies.get(parent.id()));\n    }\n\n    public List<Email> allMessages() {\n        var unordered = Stream.concat(Stream.of(List.of(first)), replies.values().stream())\n                             .flatMap(Collection::stream)\n                             .collect(Collectors.toMap(Email::id, Function.identity()));\n        return replies.keySet().stream()\n                      .map(unordered::get)\n                      .collect(Collectors.toList());\n    }\n\n    public Email parent(Email email) {\n        return parents.get(email.id());\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        Conversation that = (Conversation) o;\n        return Objects.equals(first, that.first) &&\n                Objects.equals(replies, that.replies) &&\n                Objects.equals(parents, that.parents);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(first, replies, parents);\n    }\n}\n"
  },
  {
    "path": "mailinglist/src/main/java/org/openjdk/skara/mailinglist/MailingListReader.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist;\n\nimport java.time.Duration;\nimport java.util.List;\n\npublic interface MailingListReader {\n    List<Conversation> conversations(Duration maxAge);\n}\n"
  },
  {
    "path": "mailinglist/src/main/java/org/openjdk/skara/mailinglist/MailingListServer.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist;\n\nimport org.openjdk.skara.email.Email;\nimport org.openjdk.skara.email.EmailAddress;\n\npublic interface MailingListServer {\n    MailingListReader getListReader(EmailAddress... listNames);\n    void post(Email email);\n}\n"
  },
  {
    "path": "mailinglist/src/main/java/org/openjdk/skara/mailinglist/MailingListServerFactory.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist;\n\nimport org.openjdk.skara.mailinglist.mailman.Mailman2Server;\nimport org.openjdk.skara.mailinglist.mailman.Mailman3Server;\nimport org.openjdk.skara.mailinglist.mailman.SendOnlyServer;\nimport org.openjdk.skara.mailinglist.mboxfile.MboxFileListServer;\n\nimport java.net.URI;\nimport java.nio.file.Path;\nimport java.time.Duration;\n\npublic class MailingListServerFactory {\n    public static MailingListServer createMailman2Server(URI archive, String smtp, Duration sendInterval) {\n        return new Mailman2Server(archive, smtp, sendInterval, false);\n    }\n\n    public static MailingListServer createMailman2Server(URI archive, String smtp, Duration sendInterval, boolean useEtag) {\n        return new Mailman2Server(archive, smtp, sendInterval, useEtag);\n    }\n\n    public static MailingListServer createMailman3Server(URI archive, String smtp, Duration sendInterval) {\n        return new Mailman3Server(archive, smtp, sendInterval);\n    }\n\n    public static MailingListServer createSendOnlyServer(String smtp, Duration sendInterval) {\n        return new SendOnlyServer(smtp, sendInterval);\n    }\n\n    public static MailingListServer createMboxFileServer(Path file) {\n        return new MboxFileListServer(file);\n    }\n}\n"
  },
  {
    "path": "mailinglist/src/main/java/org/openjdk/skara/mailinglist/Mbox.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist;\n\nimport java.util.logging.Level;\nimport org.openjdk.skara.email.*;\n\nimport java.io.*;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\nimport java.util.function.Function;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class Mbox {\n    private final static Logger log = Logger.getLogger(\"org.openjdk.skara.mailinglist\");\n\n    private final static Pattern mboxMessagePattern = Pattern.compile(\n            \"^(From (?:.(?!^\\\\R^From ))*)\", Pattern.MULTILINE | Pattern.DOTALL);\n    private final static DateTimeFormatter ctimeFormat = DateTimeFormatter.ofPattern(\n            \"EEE LLL dd HH:mm:ss yyyy\", Locale.US);\n    private final static Pattern fromStringEncodePattern = Pattern.compile(\"^(>*From )\", Pattern.MULTILINE);\n    private final static Pattern fromStringDecodePattern = Pattern.compile(\"^>(>*From )\", Pattern.MULTILINE);\n\n    public static List<Email> splitMbox(String mbox, EmailAddress sender) {\n        // Initial split\n        var messages = mboxMessagePattern.matcher(mbox).results()\n                                         .map(match -> match.group(1))\n                                         .filter(message -> message.length() > 0)\n                                         .map(Mbox::decodeFromStrings)\n                                         .collect(Collectors.toList());\n\n        // Pipermail can occasionally fail to encode 'From ' in message bodies, try to handle this\n        var messageBuilder = new StringBuilder();\n        var parsedMails = new ArrayList<Email>();\n        Collections.reverse(messages);\n        for (var message : messages) {\n            messageBuilder.insert(0, message);\n            try {\n                var email = Email.from(Email.parse(messageBuilder.toString()));\n                if (sender != null) {\n                    email.sender(sender);\n                }\n                parsedMails.add(email.build());\n                messageBuilder.setLength(0);\n            } catch (RuntimeException e) {\n                log.log(Level.WARNING, \"Failed to parse email: \" + e.getMessage(), e);\n            }\n        }\n\n        Collections.reverse(parsedMails);\n        return parsedMails;\n    }\n\n    private static String encodeFromStrings(String body) {\n        var fromStringMatcher = fromStringEncodePattern.matcher(body);\n        return fromStringMatcher.replaceAll(\">$1\");\n    }\n\n    private static String decodeFromStrings(String body) {\n        var fromStringMatcher = fromStringDecodePattern.matcher(body);\n        return fromStringMatcher.replaceAll(\"$1\");\n    }\n\n    private static final Pattern inReplyToPattern = Pattern.compile(\"<(.*@.*?)>\");\n\n    public static List<Conversation> parseMbox(List<Email> emails) {\n        var idToMail = emails.stream().collect(Collectors.toMap(Email::id, Function.identity(), (a, b) -> a));\n        var idToConversation = idToMail.values().stream()\n                                       .filter(email -> !email.hasHeader(\"In-Reply-To\"))\n                                       .collect(Collectors.toMap(Email::id, Conversation::new));\n\n        var unhandledEmails = emails;\n        var lastUnhandledCount = 0;\n        while (unhandledEmails.size() != lastUnhandledCount) {\n            lastUnhandledCount = unhandledEmails.size();\n            var emailsToCheck = unhandledEmails;\n            unhandledEmails = new ArrayList<>();\n\n            for (var email : emailsToCheck) {\n                if (!idToConversation.containsKey(email.id())) {\n                    EmailAddress inReplyTo = findInReplyTo(idToMail, email);\n                    if (inReplyTo != null) {\n                        if (!idToConversation.containsKey(inReplyTo)) {\n                            unhandledEmails.add(email);\n                        } else {\n                            var conversation = idToConversation.get(inReplyTo);\n                            var parent = idToMail.get(inReplyTo);\n                            conversation.addReply(parent, email);\n                            idToConversation.put(email.id(), conversation);\n                        }\n                    }\n                }\n            }\n        }\n        if (!unhandledEmails.isEmpty()) {\n            log.info(\"Out of order remaining: \" + unhandledEmails.size());\n            unhandledEmails.forEach(oo -> log.info(\"  \" + oo.id()));\n        }\n\n        return idToConversation.values().stream()\n                               .distinct()\n                               .collect(Collectors.toList());\n    }\n\n    private static EmailAddress findInReplyTo(Map<EmailAddress, Email> idToMail, Email email) {\n        if (email.hasHeader(\"In-Reply-To\")) {\n            var inReplyToMatcher = inReplyToPattern.matcher(email.headerValue(\"In-Reply-To\"));\n            if (!inReplyToMatcher.find()) {\n                log.info(\"Cannot parse In-Reply-To header: \" + email.headerValue(\"In-Reply-To\"));\n            } else {\n                var inReplyTo = EmailAddress.from(inReplyToMatcher.group(1));\n                if (idToMail.containsKey(inReplyTo)) {\n                    return inReplyTo;\n                }\n            }\n        }\n        if (email.hasHeader(\"References\")) {\n            var references = email.headerValue(\"References\");\n            var referenceList = Arrays.asList(references.split(\"\\\\s+\"));\n            Collections.reverse(referenceList);\n            for (String reference : referenceList) {\n                var referenceMatcher = inReplyToPattern.matcher(reference);\n                if (referenceMatcher.find()) {\n                    var referenceAddress = EmailAddress.from(referenceMatcher.group(1));\n                    if (idToMail.containsKey(referenceAddress)) {\n                        return referenceAddress;\n                    }\n                }\n            }\n        }\n        log.info(\"Can't find parent for: \" + email.id() + \" - discarding\");\n        return null;\n    }\n\n    public static String fromMail(Email mail) {\n        var mboxString = new StringWriter();\n        var mboxMail = new PrintWriter(mboxString);\n\n        mboxMail.println();\n        mboxMail.println(\"From \" + mail.sender().address() + \"  \" + mail.date().format(ctimeFormat));\n        mboxMail.println(\"From: \" + MimeText.encode(mail.author().toObfuscatedString()));\n        if (!mail.author().equals(mail.sender())) {\n            mboxMail.println(\"Sender: \" + MimeText.encode(mail.sender().toObfuscatedString()));\n        }\n        if (!mail.recipients().isEmpty()) {\n            mboxMail.println(\"To: \" + mail.recipients().stream()\n                                          .map(EmailAddress::toString)\n                                          .map(MimeText::encode)\n                                          .collect(Collectors.joining(\", \")));\n        }\n        mboxMail.println(\"Date: \" + mail.date().format(DateTimeFormatter.RFC_1123_DATE_TIME));\n        mboxMail.println(\"Subject: \" + MimeText.encode(mail.subject()));\n        mboxMail.println(\"Message-Id: \" + mail.id());\n        mail.headers().forEach(header -> mboxMail.println(header + \": \" + MimeText.encode(mail.headerValue(header))));\n        mboxMail.println();\n        mboxMail.println(encodeFromStrings(mail.body()));\n\n        return mboxString.toString();\n    }\n}\n"
  },
  {
    "path": "mailinglist/src/main/java/org/openjdk/skara/mailinglist/mailman/Mailman2Server.java",
    "content": "/*\n * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist.mailman;\n\nimport java.net.URI;\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport java.time.format.DateTimeFormatter;\nimport java.util.Arrays;\nimport java.util.Locale;\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.mailinglist.MailingListReader;\nimport org.openjdk.skara.network.URIBuilder;\n\npublic class Mailman2Server extends MailmanServer {\n\n    public Mailman2Server(URI archive, String smtpServer, Duration sendInterval, boolean useEtag) {\n        super(archive, smtpServer, sendInterval, useEtag);\n    }\n\n    URI getMboxUri(EmailAddress listName, ZonedDateTime month) {\n        var dateStr = DateTimeFormatter.ofPattern(\"yyyy-MMMM\", Locale.US).format(month);\n        return URIBuilder.base(archive).appendPath(listName.localPart() + \"/\" + dateStr + \".txt\").build();\n    }\n\n    @Override\n    public MailingListReader getListReader(EmailAddress... listNames) {\n        return new Mailman2ListReader(this, Arrays.asList(listNames), useEtag);\n    }\n}\n"
  },
  {
    "path": "mailinglist/src/main/java/org/openjdk/skara/mailinglist/mailman/Mailman3Server.java",
    "content": "/*\n * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist.mailman;\n\nimport java.net.URI;\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport java.util.Arrays;\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.mailinglist.MailingListReader;\n\npublic class Mailman3Server extends MailmanServer {\n    private final ZonedDateTime startTime;\n\n    public Mailman3Server(URI archive, String smtpServer, Duration sendInterval, ZonedDateTime startTime) {\n        super(archive, smtpServer, sendInterval, false);\n        this.startTime = startTime;\n    }\n\n    public Mailman3Server(URI archive, String smtpServer, Duration sendInterval) {\n        this(archive, smtpServer, sendInterval, ZonedDateTime.now());\n    }\n\n    URI getArchiveUri() {\n        return archive;\n    }\n\n    @Override\n    public MailingListReader getListReader(EmailAddress... listNames) {\n        return new Mailman3ListReader(this, Arrays.asList(listNames), startTime);\n    }\n}\n"
  },
  {
    "path": "mailinglist/src/main/java/org/openjdk/skara/mailinglist/mailman/MailmanListReader.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist.mailman;\n\nimport java.nio.charset.StandardCharsets;\nimport java.time.format.DateTimeFormatter;\nimport java.util.zip.GZIPInputStream;\nimport org.openjdk.skara.email.*;\nimport org.openjdk.skara.mailinglist.*;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.net.http.*;\nimport java.time.*;\nimport java.util.*;\nimport java.util.concurrent.*;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\nimport org.openjdk.skara.metrics.Counter;\nimport org.openjdk.skara.network.URIBuilder;\n\npublic abstract class MailmanListReader implements MailingListReader {\n    private final boolean useEtag;\n    protected final List<EmailAddress> names;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.mailinglist\");\n    protected final ConcurrentMap<URI, HttpResponse<byte[]>> pageCache = new ConcurrentHashMap<>();\n    protected List<Conversation> cachedConversations = new ArrayList<>();\n    private static final HttpClient client = HttpClient.newBuilder()\n                                                       .connectTimeout(Duration.ofSeconds(10))\n                                                       .build();\n\n    private static final Counter.WithOneLabel POLLING_COUNTER =\n            Counter.name(\"skara_mailman_polling\").labels(\"code\").register();\n\n    MailmanListReader(Collection<EmailAddress> names, boolean useEtag) {\n        this.useEtag = useEtag;\n        this.names = List.copyOf(names);\n    }\n\n    protected Optional<HttpResponse<byte[]>> getPage(URI uri) {\n        var requestBuilder = HttpRequest.newBuilder(uri)\n                                        .timeout(Duration.ofSeconds(30))\n                                        .GET();\n\n        var cached = pageCache.get(uri);\n        if (useEtag && cached != null) {\n            var etag = cached.headers().firstValue(\"ETag\");\n            etag.ifPresent(s -> requestBuilder.header(\"If-None-Match\", s));\n        }\n\n        var request = requestBuilder.build();\n        try {\n            HttpResponse<byte[]> response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());\n            POLLING_COUNTER.labels(String.valueOf(response.statusCode())).inc();\n            if (response.statusCode() == 200) {\n                pageCache.put(uri, response);\n                return Optional.of(response);\n            } else if (response.statusCode() == 304) {\n                return Optional.of(response);\n            } else if (response.statusCode() == 404) {\n                pageCache.put(uri, response);\n                log.fine(\"Page not found for \" + uri);\n                return Optional.empty();\n            } else {\n                throw new RuntimeException(\"Bad response received: \" + response);\n            }\n        } catch (IOException e) {\n            POLLING_COUNTER.labels(e.getMessage()).inc();\n            throw new UncheckedIOException(e);\n        } catch (InterruptedException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    protected void updateCachedConversations(ArrayList<Email> emails, Duration maxAge) {\n        var conversations = Mbox.parseMbox(emails);\n        var threshold = ZonedDateTime.now().minus(maxAge);\n        cachedConversations = conversations.stream()\n                .filter(mail -> mail.first().date().isAfter(threshold))\n                .collect(Collectors.toList());\n    }\n}\n\nclass Mailman2ListReader extends MailmanListReader {\n    private final Mailman2Server server;\n\n    Mailman2ListReader(Mailman2Server server, Collection<EmailAddress> names, boolean useEtag) {\n        super(names, useEtag);\n        this.server = server;\n    }\n\n    @Override\n    public String toString() {\n        return \"Mailman2List:\" + names;\n    }\n\n    private List<ZonedDateTime> getMonthRange(Duration maxAge) {\n        var now = ZonedDateTime.now();\n        var start = now.minus(maxAge);\n        List<ZonedDateTime> ret = new ArrayList<>();\n\n        // Iterate all the way until start is equal to now\n        while (!start.isAfter(now)) {\n            ret.add(start);\n            var next = start.plus(Duration.ofDays(1));\n            while (start.getMonthValue() == next.getMonthValue()) {\n                next = next.plus(Duration.ofDays(1));\n            }\n            start = next;\n        }\n        return ret;\n    }\n\n    @Override\n    public List<Conversation> conversations(Duration maxAge) {\n        // Order pages by most recent first\n        var potentialPages = getMonthRange(maxAge).stream()\n                                                  .sorted(Comparator.reverseOrder())\n                                                  .toList();\n\n        var monthCount = 0;\n        var newContent = false;\n        var emails = new ArrayList<Email>();\n        for (var month : potentialPages) {\n            for (var name : names) {\n                URI mboxUri = server.getMboxUri(name, month);\n                var sender = EmailAddress.from(name.localPart() + \"@\" + mboxUri.getHost());\n\n                // For archives older than the previous month, always use cached results\n                if (monthCount > 1 && pageCache.containsKey(mboxUri)) {\n                    var cachedResponse = pageCache.get(mboxUri);\n                    if (cachedResponse != null && cachedResponse.statusCode() != 404) {\n                        emails.addAll(0, Mbox.splitMbox(new String(cachedResponse.body(), StandardCharsets.UTF_8), sender));\n                    }\n                } else {\n                    var mboxResponse = getPage(mboxUri);\n                    if (mboxResponse.isPresent()) {\n                        if (mboxResponse.get().statusCode() == 304) {\n                            emails.addAll(0, Mbox.splitMbox(new String(pageCache.get(mboxUri).body(), StandardCharsets.UTF_8), sender));\n                        } else {\n                            emails.addAll(0, Mbox.splitMbox(new String(mboxResponse.get().body(), StandardCharsets.UTF_8), sender));\n                            newContent = true;\n                        }\n                    }\n                }\n            }\n            monthCount++;\n        }\n\n        if (newContent) {\n            updateCachedConversations(emails, maxAge);\n        }\n\n        return cachedConversations;\n    }\n}\n\nclass Mailman3ListReader extends MailmanListReader {\n    private final Mailman3Server server;\n    private final ZonedDateTime startTime;\n\n    Mailman3ListReader(Mailman3Server server, Collection<EmailAddress> names, ZonedDateTime startTime) {\n        // Mailman3 does not support etag for mbox API\n        super(names, false);\n        this.server = server;\n        this.startTime = startTime;\n    }\n\n    /**\n     * Reads all emails newer than maxAge. Reads everything older than start\n     * time in one go and caches that result. This chunk will always read start\n     * time minus max age. Emails older than now minus max age are filtered out\n     * later. Chunks newer than start time are read one day at a time, each day\n     * getting cached when the next day starts. This means only the current day\n     * is refreshed each time this method is called.\n     *\n     * @param maxAge Maximum age of emails to read relative to the start time.\n     * @return Emails sorted in conversations\n     */\n    @Override\n    public List<Conversation> conversations(Duration maxAge) {\n        var now = ZonedDateTime.now();\n        // First interval is everything before start time\n        var start = startTime.minus(maxAge);\n        var end = startTime.plusDays(1);\n\n        var emails = new ArrayList<Email>();\n        var newContent = false;\n        // https://mail-dev.example.com/archives/list/skara-test@mail-dev.example.com/export/foo.mbox.gz?start=2024-10-25&end=2025-10-25\n\n        while (start.isBefore(now)) {\n            var query = Map.of(\"start\", List.of(start.format(DateTimeFormatter.ISO_LOCAL_DATE)),\n                    \"end\", List.of(end.format(DateTimeFormatter.ISO_LOCAL_DATE)));\n            for (EmailAddress name : names) {\n                var mboxUri = URIBuilder.base(server.getArchiveUri()).appendPath(\"list/\").appendPath(name.address())\n                        .appendPath(\"/export/foo.mbox.gz\").setQuery(query).build();\n                var sender = EmailAddress.from(name.localPart() + \"@\" + server.getArchiveUri().getHost());\n                // For archives older than today, always use cached results\n                if (end.isBefore(now) && pageCache.containsKey(mboxUri)) {\n                    var cachedResponse = pageCache.get(mboxUri);\n                    if (cachedResponse != null && cachedResponse.statusCode() != 404) {\n                        emails.addAll(0, Mbox.splitMbox(gunzipToString(cachedResponse.body()), sender));\n                    }\n                } else {\n                    var mboxResponse = getPage(mboxUri);\n                    if (mboxResponse.isPresent()) {\n                        emails.addAll(0, Mbox.splitMbox(gunzipToString(mboxResponse.get().body()), sender));\n                        newContent = true;\n                    }\n                }\n            }\n            // Every interval after the first is one day\n            start = end;\n            end = end.plusDays(1);\n        }\n\n        if (newContent) {\n            updateCachedConversations(emails, maxAge);\n        }\n\n        return cachedConversations;\n    }\n\n    private String gunzipToString(byte[] data) {\n        try (var in = new InputStreamReader(new GZIPInputStream(new ByteArrayInputStream(data)), StandardCharsets.UTF_8)) {\n            var out = new StringBuilderWriter();\n            in.transferTo(out);\n            return out.toString();\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    /**\n     * Simple StringWriter clone that uses a StringBuilder instead of a StringBuffer.\n     */\n    private static class StringBuilderWriter extends Writer {\n\n        private final StringBuilder buf = new StringBuilder();\n\n        @Override\n        public String toString() {\n            return buf.toString();\n        }\n\n        @Override\n        public void write(char[] cbuf, int off, int len) {\n            buf.append(cbuf, off, len);\n        }\n\n        @Override\n        public void flush() {\n        }\n\n        @Override\n        public void close() {\n        }\n    }\n}\n"
  },
  {
    "path": "mailinglist/src/main/java/org/openjdk/skara/mailinglist/mailman/MailmanServer.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist.mailman;\n\nimport org.openjdk.skara.email.*;\nimport org.openjdk.skara.mailinglist.*;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.time.*;\n\npublic abstract class MailmanServer implements MailingListServer {\n    protected final URI archive;\n    private final String smtpServer;\n    private final Duration sendInterval;\n    protected final boolean useEtag;\n    private volatile Instant lastSend;\n\n    protected MailmanServer(URI archive, String smtpServer, Duration sendInterval, boolean useEtag) {\n        this.archive = archive;\n        this.smtpServer = smtpServer;\n        this.sendInterval = sendInterval;\n        this.useEtag = useEtag;\n        lastSend = Instant.EPOCH;\n    }\n\n    void sendMessage(Email message) {\n        while (lastSend.plus(sendInterval).isAfter(Instant.now())) {\n            try {\n                Thread.sleep(sendInterval.dividedBy(10));\n            } catch (InterruptedException ignored) {\n            }\n        }\n        lastSend = Instant.now();\n        try {\n            SMTP.send(smtpServer, message);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public void post(Email email) {\n        sendMessage(email);\n    }\n\n    public URI archive() {\n        return archive;\n    }\n}\n"
  },
  {
    "path": "mailinglist/src/main/java/org/openjdk/skara/mailinglist/mailman/SendOnlyServer.java",
    "content": "/*\n * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist.mailman;\n\nimport java.time.Duration;\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.mailinglist.MailingListReader;\n\n/**\n * MailingListServer implementation that only implements the send message API.\n */\npublic class SendOnlyServer extends MailmanServer {\n    public SendOnlyServer(String smtpServer, Duration sendInterval) {\n        super(null, smtpServer, sendInterval, false);\n    }\n\n    @Override\n    public MailingListReader getListReader(EmailAddress... listNames) {\n        throw new UnsupportedOperationException();\n    }\n}\n"
  },
  {
    "path": "mailinglist/src/main/java/org/openjdk/skara/mailinglist/mboxfile/MboxFileListReader.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist.mboxfile;\n\nimport org.openjdk.skara.email.*;\nimport org.openjdk.skara.mailinglist.*;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.time.*;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\npublic class MboxFileListReader implements MailingListReader {\n    private final Path base;\n    private final Collection<EmailAddress> names;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.mailinglist\");\n\n    MboxFileListReader(Path base, Collection<EmailAddress> names) {\n        this.base = base;\n        this.names = names;\n    }\n\n    @Override\n    public List<Conversation> conversations(Duration maxAge) {\n        var emails = new ArrayList<Email>();\n        for (var name : names) {\n            try {\n                var file = base.resolve(name.localPart() + \".mbox\");\n                var currentMbox = Files.readString(file);\n                emails.addAll(Mbox.splitMbox(currentMbox, name));\n            } catch (IOException e) {\n                log.info(\"Failed to open mbox file\");\n            }\n        }\n        if (emails.isEmpty()) {\n            return new LinkedList<>();\n        }\n        var cutoff = Instant.now().minus(maxAge);\n        return Mbox.parseMbox(emails).stream()\n                   .filter(email -> email.first().date().toInstant().isAfter(cutoff))\n                   .collect(Collectors.toList());\n    }\n}\n"
  },
  {
    "path": "mailinglist/src/main/java/org/openjdk/skara/mailinglist/mboxfile/MboxFileListServer.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist.mboxfile;\n\nimport org.openjdk.skara.email.Email;\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.mailinglist.*;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.Arrays;\nimport java.util.stream.Collectors;\n\npublic class MboxFileListServer implements MailingListServer {\n    Path base;\n\n    public MboxFileListServer(Path base) {\n        this.base = base;\n    }\n\n    private void postNewConversation(Path file, Email mail) {\n        var mboxMail = Mbox.fromMail(mail);\n        if (Files.notExists(file)) {\n            if (Files.notExists(file.getParent())) {\n                try {\n                    Files.createDirectories(file.getParent());\n                } catch (IOException e) {\n                    throw new UncheckedIOException(e);\n                }\n            }\n        }\n        try {\n            Files.writeString(file, mboxMail, StandardOpenOption.APPEND);\n        } catch (IOException e) {\n            try {\n                Files.writeString(file, mboxMail, StandardOpenOption.CREATE_NEW);\n            } catch (IOException e1) {\n                throw new UncheckedIOException(e);\n            }\n        }\n    }\n\n    private void postReply(Path file, Email mail) {\n        var mboxMail = Mbox.fromMail(mail);\n        try {\n            Files.writeString(file, mboxMail, StandardOpenOption.APPEND);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public void post(Email email) {\n        var recipientList = email.recipients().stream()\n                                 .map(e -> base.resolve(e.localPart() + \".mbox\"))\n                                 .collect(Collectors.toList());\n\n        if (email.hasHeader((\"In-Reply-To\"))) {\n            recipientList.forEach(list -> postReply(list, email));\n        } else {\n            recipientList.forEach(list -> postNewConversation(list, email));\n        }\n    }\n\n    @Override\n    public MailingListReader getListReader(EmailAddress... listNames) {\n        return new MboxFileListReader(base, Arrays.asList(listNames));\n    }\n}\n"
  },
  {
    "path": "mailinglist/src/test/java/org/openjdk/skara/mailinglist/Mailman2Tests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist;\n\nimport java.time.ZonedDateTime;\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.email.*;\nimport org.openjdk.skara.test.TestMailmanServer;\n\nimport java.io.IOException;\nimport java.time.Duration;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass Mailman2Tests {\n    @Test\n    void simple() throws IOException {\n        try (var testServer = TestMailmanServer.createV2()) {\n            var listAddress = testServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman2Server(testServer.getArchive(), testServer.getSMTP(),\n                                                                             Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var sender = EmailAddress.from(\"Test\", \"test@test.email\");\n            var mail = Email.create(sender, \"Subject\", \"Body\")\n                            .recipient(listAddress)\n                            .build();\n            mailmanServer.post(mail);\n            var expectedMail = Email.from(mail)\n                                    .sender(listAddress)\n                                    .build();\n\n            testServer.processIncoming();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(expectedMail, conversation.first());\n        }\n    }\n\n    @Test\n    void replies() throws IOException {\n        try (var testServer = TestMailmanServer.createV2()) {\n            var listAddress = testServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman2Server(testServer.getArchive(), testServer.getSMTP(),\n                                                                             Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var sender = EmailAddress.from(\"Test\", \"test@test.email\");\n            var sentParent = Email.create(sender, \"Subject\", \"Body\")\n                                  .recipient(listAddress)\n                                  .build();\n            mailmanServer.post(sentParent);\n            testServer.processIncoming();\n            var expectedParent = Email.from(sentParent)\n                                      .sender(listAddress)\n                                      .build();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(expectedParent, conversation.first());\n\n            var replier = EmailAddress.from(\"Replier\", \"replier@test.email\");\n            var sentReply = Email.create(replier, \"Reply subject\", \"Reply body\")\n                                 .recipient(listAddress)\n                                 .header(\"In-Reply-To\", sentParent.id().toString())\n                                 .build();\n            mailmanServer.post(sentReply);\n            var expectedReply = Email.from(sentReply)\n                                     .sender(listAddress)\n                                     .build();\n\n            testServer.processIncoming();\n\n            conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            conversation = conversations.get(0);\n            assertEquals(expectedParent, conversation.first());\n\n            var replies = conversation.replies(conversation.first());\n            assertEquals(1, replies.size());\n            var reply = replies.get(0);\n            assertEquals(expectedReply, reply);\n        }\n    }\n\n    @Test\n    void cached() throws IOException {\n        try (var testServer = TestMailmanServer.createV2()) {\n            var listAddress = testServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman2Server(testServer.getArchive(), testServer.getSMTP(),\n                                                                             Duration.ZERO, true);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var sender = EmailAddress.from(\"Test\", \"test@test.email\");\n            var mail = Email.create(sender, \"Subject\", \"Body\")\n                            .recipient(listAddress)\n                            .build();\n            mailmanServer.post(mail);\n            testServer.processIncoming();\n\n            var expectedMail = Email.from(mail)\n                                    .sender(listAddress)\n                                    .build();\n            {\n                var conversations = mailmanList.conversations(Duration.ofDays(1));\n                assertEquals(1, conversations.size());\n                var conversation = conversations.get(0);\n                assertEquals(expectedMail, conversation.first());\n                assertFalse(testServer.lastResponseCached());\n            }\n            {\n                var conversations = mailmanList.conversations(Duration.ofDays(1));\n                assertEquals(1, conversations.size());\n                var conversation = conversations.get(0);\n                assertEquals(expectedMail, conversation.first());\n                assertTrue(testServer.lastResponseCached());\n            }\n        }\n    }\n\n    @Test\n    void interval() throws IOException {\n        try (var testServer = TestMailmanServer.createV2()) {\n            var listAddress = testServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman2Server(testServer.getArchive(), testServer.getSMTP(),\n                                                                             Duration.ofDays(1));\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var sender = EmailAddress.from(\"Test\", \"test@test.email\");\n            var mail1 = Email.create(sender, \"Subject 1\", \"Body 1\")\n                             .recipient(listAddress)\n                             .build();\n            var mail2 = Email.create(sender, \"Subject 2\", \"Body 2\")\n                             .recipient(listAddress)\n                             .build();\n            new Thread(() -> {\n                mailmanServer.post(mail1);\n                mailmanServer.post(mail2);\n            }).start();\n            var expectedMail = Email.from(mail1)\n                                    .sender(listAddress)\n                                    .build();\n\n            testServer.processIncoming();\n            assertThrows(RuntimeException.class, () -> testServer.processIncoming(Duration.ZERO));\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(expectedMail, conversation.first());\n        }\n    }\n\n    @Test\n    void poll3months() throws Exception {\n        try (var testServer = TestMailmanServer.createV2()) {\n            var listAddress = testServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman2Server(testServer.getArchive(),\n                    testServer.getSMTP(), Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var sender = EmailAddress.from(\"Test\", \"test@test.email\");\n            var now = ZonedDateTime.now();\n            var mail2monthsAgo = Email.create(sender, \"Subject 2 months ago\", \"Body 1\")\n                    .recipient(listAddress)\n                    .date(now.minusMonths(2))\n                    .build();\n            var mail1monthAgo = Email.create(sender, \"Subject 1 month ago\", \"Body 2\")\n                    .recipient(listAddress)\n                    .date(now.minusMonths(1))\n                    .build();\n            var mailNow = Email.create(sender, \"Subject now\", \"Body 3\")\n                    .recipient(listAddress)\n                    .build();\n\n            var duration2Months = Duration.between(now.minusMonths(2), now);\n            {\n                var conversations = mailmanList.conversations(duration2Months);\n                assertEquals(0, conversations.size());\n                assertEquals(3, testServer.callCount(), \"Server wasn't called for every month\");\n            }\n            {\n                // A 2 months old mail should not be picked up now as old results should be cached\n                mailmanServer.post(mail2monthsAgo);\n                testServer.processIncoming();\n                testServer.resetCallCount();\n                var conversations = mailmanList.conversations(duration2Months);\n                assertEquals(0, conversations.size());\n                //\n                assertEquals(2, testServer.callCount(), \"Server should only be called for the current and previous month\");\n            }\n            {\n                // A mail from last month should be found\n                mailmanServer.post(mail1monthAgo);\n                testServer.processIncoming();\n                testServer.resetCallCount();\n                var conversations = mailmanList.conversations(duration2Months);\n                assertEquals(1, conversations.size());\n                assertEquals(2, testServer.callCount());\n            }\n            {\n                // A current mail should be found\n                mailmanServer.post(mailNow);\n                testServer.processIncoming();\n                testServer.resetCallCount();\n                var conversations = mailmanList.conversations(duration2Months);\n                assertEquals(2, conversations.size());\n                assertEquals(2, testServer.callCount());\n            }\n            {\n                // Another mail from last month should be found\n                var mail1monthAgo2 = Email.create(sender, \"Subject 1 month ago 2\", \"Body 2\")\n                        .recipient(listAddress)\n                        .date(now.minusMonths(1))\n                        .build();\n                mailmanServer.post(mail1monthAgo2);\n                testServer.processIncoming();\n                testServer.resetCallCount();\n                var conversations = mailmanList.conversations(duration2Months);\n                assertEquals(3, conversations.size());\n                assertEquals(2, testServer.callCount());\n            }\n            {\n                // Another current mail should be found\n                var mailNow2 = Email.create(sender, \"Subject now 2\", \"Body 3\")\n                        .recipient(listAddress)\n                        .build();\n                mailmanServer.post(mailNow2);\n                testServer.processIncoming();\n                testServer.resetCallCount();\n                var conversations = mailmanList.conversations(duration2Months);\n                assertEquals(4, conversations.size());\n                assertEquals(2, testServer.callCount());\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "mailinglist/src/test/java/org/openjdk/skara/mailinglist/Mailman3IntegrationTests.java",
    "content": "/*\n * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist;\n\nimport java.time.Duration;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.test.EnabledIfTestProperties;\nimport org.openjdk.skara.test.TestProperties;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\n\npublic class Mailman3IntegrationTests {\n\n    private static TestProperties props;\n\n    @BeforeAll\n    static void beforeAll() {\n        props = TestProperties.load();\n    }\n\n    @Test\n    @EnabledIfTestProperties({\"mailman3.url\", \"mailman3.list\"})\n    void testReviews() {\n        var url = props.get(\"mailman3.url\");\n        var listName = EmailAddress.parse(props.get(\"mailman3.list\"));\n        var mailmanServer = MailingListServerFactory.createMailman3Server(URIBuilder.base(url).build(), null, null);\n        var listReader = mailmanServer.getListReader(listName);\n        var conversations = listReader.conversations(Duration.ofDays(365));\n        assertFalse(conversations.isEmpty());\n    }\n}\n"
  },
  {
    "path": "mailinglist/src/test/java/org/openjdk/skara/mailinglist/Mailman3Tests.java",
    "content": "/*\n * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist;\n\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.email.Email;\nimport org.openjdk.skara.email.EmailAddress;\nimport org.openjdk.skara.mailinglist.mailman.Mailman3Server;\nimport org.openjdk.skara.test.TestMailmanServer;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass Mailman3Tests {\n    @Test\n    void simple() throws IOException {\n        try (var testServer = TestMailmanServer.createV3()) {\n            var listAddress = testServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(testServer.getArchive(), testServer.getSMTP(),\n                                                                             Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var sender = EmailAddress.from(\"Test\", \"test@test.email\");\n            var mail = Email.create(sender, \"Subject\", \"Body\")\n                            .recipient(listAddress)\n                            .build();\n            mailmanServer.post(mail);\n            var expectedMail = Email.from(mail)\n                                    .sender(listAddress)\n                                    .build();\n\n            testServer.processIncoming();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(expectedMail, conversation.first());\n        }\n    }\n\n    @Test\n    void replies() throws IOException {\n        try (var testServer = TestMailmanServer.createV3()) {\n            var listAddress = testServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(testServer.getArchive(), testServer.getSMTP(),\n                                                                             Duration.ZERO);\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var sender = EmailAddress.from(\"Test\", \"test@test.email\");\n            var sentParent = Email.create(sender, \"Subject\", \"Body\")\n                                  .recipient(listAddress)\n                                  .build();\n            mailmanServer.post(sentParent);\n            testServer.processIncoming();\n            var expectedParent = Email.from(sentParent)\n                                      .sender(listAddress)\n                                      .build();\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(expectedParent, conversation.first());\n\n            var replier = EmailAddress.from(\"Replier\", \"replier@test.email\");\n            var sentReply = Email.create(replier, \"Reply subject\", \"Reply body\")\n                                 .recipient(listAddress)\n                                 .header(\"In-Reply-To\", sentParent.id().toString())\n                                 .build();\n            mailmanServer.post(sentReply);\n            var expectedReply = Email.from(sentReply)\n                                     .sender(listAddress)\n                                     .build();\n\n            testServer.processIncoming();\n\n            conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            conversation = conversations.get(0);\n            assertEquals(expectedParent, conversation.first());\n\n            var replies = conversation.replies(conversation.first());\n            assertEquals(1, replies.size());\n            var reply = replies.get(0);\n            assertEquals(expectedReply, reply);\n        }\n    }\n\n    @Test\n    void interval() throws IOException {\n        try (var testServer = TestMailmanServer.createV3()) {\n            var listAddress = testServer.createList(\"test\");\n            var mailmanServer = MailingListServerFactory.createMailman3Server(testServer.getArchive(), testServer.getSMTP(),\n                                                                             Duration.ofDays(1));\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var sender = EmailAddress.from(\"Test\", \"test@test.email\");\n            var mail1 = Email.create(sender, \"Subject 1\", \"Body 1\")\n                             .recipient(listAddress)\n                             .build();\n            var mail2 = Email.create(sender, \"Subject 2\", \"Body 2\")\n                             .recipient(listAddress)\n                             .build();\n            new Thread(() -> {\n                mailmanServer.post(mail1);\n                mailmanServer.post(mail2);\n            }).start();\n            var expectedMail = Email.from(mail1)\n                                    .sender(listAddress)\n                                    .build();\n\n            testServer.processIncoming();\n            assertThrows(RuntimeException.class, () -> testServer.processIncoming(Duration.ZERO));\n\n            var conversations = mailmanList.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(expectedMail, conversation.first());\n        }\n    }\n\n    @Test\n    void poll3days() throws Exception {\n        try (var testServer = TestMailmanServer.createV3()) {\n            var listAddress = testServer.createList(\"test\");\n            var now = ZonedDateTime.now();\n            var mailmanServer = new Mailman3Server(testServer.getArchive(),\n                    testServer.getSMTP(), Duration.ZERO, now.minusDays(2));\n            var mailmanList = mailmanServer.getListReader(listAddress);\n            var sender = EmailAddress.from(\"Test\", \"test@test.email\");\n\n            var duration3Days = Duration.between(now.minusDays(3), now);\n\n            {\n                // A 2 days old should be picked up\n                var mail2daysAgo = Email.create(sender, \"Subject 2 day2 ago\", \"Body 1\")\n                        .recipient(listAddress)\n                        .date(now.minusDays(2))\n                        .build();\n                mailmanServer.post(mail2daysAgo);\n                testServer.processIncoming();\n                testServer.resetCallCount();\n                var conversations = mailmanList.conversations(duration3Days);\n                assertEquals(1, conversations.size());\n                assertEquals(3, testServer.callCount(),\n                        \"Server wasn't for initial interval plus every day since start time\");\n            }\n            {\n                // A 2 days old mail should not be picked up now as old results should be cached\n                var mail2daysAgo2 = Email.create(sender, \"Subject 2 days ago 2\", \"Body 2\")\n                        .recipient(listAddress)\n                        .date(now.minusDays(2))\n                        .build();\n                mailmanServer.post(mail2daysAgo2);\n                testServer.processIncoming();\n                testServer.resetCallCount();\n                var conversations = mailmanList.conversations(duration3Days);\n                assertEquals(1, conversations.size());\n                assertEquals(1, testServer.callCount(), \"Server wasn't called once\");\n            }\n            {\n                // A 1-day-old mail should not be picked up now as old results should be cached\n                var mail1dayAgo = Email.create(sender, \"Subject 1 day ago\", \"Body 2\")\n                        .recipient(listAddress)\n                        .date(now.minusDays(1))\n                        .build();\n                mailmanServer.post(mail1dayAgo);\n                testServer.processIncoming();\n                testServer.resetCallCount();\n                var conversations = mailmanList.conversations(duration3Days);\n                assertEquals(1, conversations.size());\n                assertEquals(1, testServer.callCount());\n            }\n            {\n                // A current mail should be found\n                var mailNow = Email.create(sender, \"Subject now\", \"Body 3\")\n                        .recipient(listAddress)\n                        .build();\n                mailmanServer.post(mailNow);\n                testServer.processIncoming();\n                testServer.resetCallCount();\n                var conversations = mailmanList.conversations(duration3Days);\n                assertEquals(2, conversations.size());\n                assertEquals(1, testServer.callCount());\n            }\n            {\n                // Another mail from last month should not be found\n                var mail1dayAgo2 = Email.create(sender, \"Subject 1 day ago 2\", \"Body 2\")\n                        .recipient(listAddress)\n                        .date(now.minusDays(1))\n                        .build();\n                mailmanServer.post(mail1dayAgo2);\n                testServer.processIncoming();\n                testServer.resetCallCount();\n                var conversations = mailmanList.conversations(duration3Days);\n                assertEquals(2, conversations.size());\n                assertEquals(1, testServer.callCount());\n            }\n            {\n                // Another current mail should be found\n                var mailNow2 = Email.create(sender, \"Subject now 2\", \"Body 3\")\n                        .recipient(listAddress)\n                        .build();\n                mailmanServer.post(mailNow2);\n                testServer.processIncoming();\n                testServer.resetCallCount();\n                var conversations = mailmanList.conversations(duration3Days);\n                assertEquals(3, conversations.size());\n                assertEquals(1, testServer.callCount());\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "mailinglist/src/test/java/org/openjdk/skara/mailinglist/MboxTests.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.mailinglist;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.email.*;\nimport org.openjdk.skara.test.TemporaryDirectory;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.time.Duration;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nclass MboxTests {\n    @Test\n    void simple() {\n        try (var folder = new TemporaryDirectory()) {\n            var mbox = MailingListServerFactory.createMboxFileServer(folder.path());\n            var listName = EmailAddress.parse(\"test@mbox\");\n            var list = mbox.getListReader(listName);\n\n            var sender = EmailAddress.from(\"test\", \"test@test.mail\");\n            var sentMail = Email.create(sender, \"Subject\", \"Message\")\n                                .recipient(EmailAddress.from(\"test@mbox\"))\n                                .build();\n            var expectedMail = Email.from(sentMail)\n                                    .sender(listName)\n                                    .build();\n            mbox.post(sentMail);\n            var conversations = list.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(expectedMail, conversation.first());\n        }\n    }\n\n    @Test\n    void multiple() {\n        try (var folder = new TemporaryDirectory()) {\n            var mbox = MailingListServerFactory.createMboxFileServer(folder.path());\n            var listName = EmailAddress.parse(\"test@mbox\");\n            var list = mbox.getListReader(listName);\n\n            var sender1 = EmailAddress.from(\"test1\", \"test1@test.mail\");\n            var sender2 = EmailAddress.from(\"test2\", \"test2@test.mail\");\n\n            var sentParent = Email.create(sender1, \"Subject 1\", \"Message 1\")\n                                  .recipient(EmailAddress.from(\"test@mbox\"))\n                                  .build();\n            var expectedParent = Email.from(sentParent)\n                                      .sender(listName)\n                                      .build();\n            mbox.post(sentParent);\n            var conversations = list.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n\n            var sentReply = Email.create(sender2, \"Subject 2\", \"Message 2\")\n                                 .recipient(EmailAddress.from(\"test@mbox\"))\n                                 .header(\"In-Reply-To\", sentParent.id().toString())\n                                 .header(\"References\", sentParent.id().toString())\n                                 .build();\n            var expectedReply = Email.from(sentReply)\n                                     .sender(listName)\n                                     .build();\n            mbox.post(sentReply);\n            conversations = list.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(expectedParent, conversation.first());\n            var replies = conversation.replies(expectedParent);\n            assertEquals(1, replies.size());\n            var reply = replies.get(0);\n            assertEquals(expectedReply, reply);\n        }\n    }\n\n    @Test\n    void uninitialized() {\n        try (var folder = new TemporaryDirectory()) {\n            var mbox = MailingListServerFactory.createMboxFileServer(folder.path());\n            var list = mbox.getListReader(EmailAddress.from(\"test@mbox\"));\n            var conversations = list.conversations(Duration.ofDays(1));\n            assertEquals(0, conversations.size());\n        }\n    }\n\n    @Test\n    void differentAuthor() {\n        try (var folder = new TemporaryDirectory()) {\n            var mbox = MailingListServerFactory.createMboxFileServer(folder.path());\n            var listName = EmailAddress.parse(\"test@mbox\");\n            var list = mbox.getListReader(listName);\n\n            var sender = EmailAddress.from(\"test1\", \"test1@test.mail\");\n            var author = EmailAddress.from(\"test2\", \"test2@test.mail\");\n            var sentMail = Email.create(author, \"Subject\", \"Message\")\n                                .recipient(EmailAddress.from(\"test@mbox\"))\n                                .sender(sender)\n                                .build();\n            var expectedMail = Email.from(sentMail)\n                                    .sender(listName)\n                                    .build();\n            mbox.post(sentMail);\n            var conversations = list.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(expectedMail, conversation.first());\n        }\n    }\n\n    @Test\n    void encodedFrom() {\n        try (var folder = new TemporaryDirectory()) {\n            var mbox = MailingListServerFactory.createMboxFileServer(folder.path());\n            var listName = EmailAddress.parse(\"test@mbox\");\n            var list = mbox.getListReader(listName);\n\n            var sender = EmailAddress.from(\"test\", \"test@test.mail\");\n            var sentMail = Email.create(sender, \"Subject\", \"\"\"\n                                    From is an odd way to start\n                                    From may also be the second row\n                                    >>From as a quote\n                                    And From in the middle\"\"\")\n                                .recipient(EmailAddress.from(\"test@mbox\"))\n                                .build();\n            var expectedMail = Email.from(sentMail)\n                                    .sender(listName)\n                                    .build();\n            mbox.post(sentMail);\n            var conversations = list.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(expectedMail, conversation.first());\n        }\n    }\n\n    @Test\n    void utf8Encode() {\n        try (var folder = new TemporaryDirectory()) {\n            var mbox = MailingListServerFactory.createMboxFileServer(folder.path());\n            var listName = EmailAddress.parse(\"test@mbox\");\n            var list = mbox.getListReader(listName);\n\n            var sender = EmailAddress.from(\"têßt\", \"test@test.mail\");\n            var sentMail = Email.create(sender, \"Sübjeçt\", \"(╯°□°)╯︵ ┻━┻\")\n                                .recipient(EmailAddress.from(\"test@mbox\"))\n                                .build();\n            var expectedMail = Email.from(sentMail)\n                                    .sender(listName)\n                                    .build();\n            mbox.post(sentMail);\n            var conversations = list.conversations(Duration.ofDays(1));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(expectedMail, conversation.first());\n        }\n    }\n\n    @Test\n    void unencodedFrom() throws IOException {\n        try (var folder = new TemporaryDirectory()) {\n            var rawMbox = folder.path().resolve(\"test.mbox\");\n            Files.writeString(rawMbox, \"\"\"\n                                      From test at example.com  Wed Aug 21 17:22:50 2019\n                                      From: test at example.com (test at example.com)\n                                      Date: Wed, 21 Aug 2019 17:22:50 +0000\n                                      Subject: this is a test\n                                      Message-ID: <abc123@example.com>\n\n                                      Sometimes there are unencoded from lines as well\n\n                                      From this point onwards, it may be hard to parse this\n                                      \"\"\");\n            var mbox = MailingListServerFactory.createMboxFileServer(folder.path());\n            var list = mbox.getListReader(EmailAddress.parse(\"test@mbox\"));\n            var conversations = list.conversations(Duration.ofDays(365 * 100));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(1, conversation.allMessages().size());\n            assertTrue(conversation.first().body().contains(\"there are unencoded\"), conversation.first().body());\n            assertTrue(conversation.first().body().contains(\"this point onwards\"), conversation.first().body());\n        }\n    }\n\n    @Test\n    void replyToWithExtra() throws IOException {\n        try (var folder = new TemporaryDirectory()) {\n            var rawMbox = folder.path().resolve(\"test.mbox\");\n            Files.writeString(rawMbox, \"\"\"\n                                      From test at example.com  Wed Aug 21 17:22:50 2019\n                                      From: test at example.com (test at example.com)\n                                      Date: Wed, 21 Aug 2019 17:22:50 +0000\n                                      Subject: this is a test\n                                      Message-ID: <abc123@example.com>\n\n                                      First message\n\n                                      From test2 at example.com  Wed Aug 21 17:32:50 2019\n                                      From: test2 at example.com (test2 at example.com)\n                                      Date: Wed, 21 Aug 2019 17:32:50 +0000\n                                      Subject: Re: this is a test\n                                      In-Reply-To: <abc123@example.com> (This be a reply)\n                                      Message-ID: <def456@example.com>\n\n                                      Second message\n                                      \"\"\");\n            var mbox = MailingListServerFactory.createMboxFileServer(folder.path());\n            var list = mbox.getListReader(EmailAddress.parse(\"test@mbox\"));\n            var conversations = list.conversations(Duration.ofDays(365 * 100));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(2, conversation.allMessages().size());\n        }\n    }\n\n    @Test\n    void replyOutOfOrder() throws IOException {\n        try (var folder = new TemporaryDirectory()) {\n            var rawMbox = folder.path().resolve(\"test.mbox\");\n            Files.writeString(rawMbox, \"\"\"\n                                      From test at example.com  Wed Aug 21 17:22:50 2019\n                                      From: test at example.com (test at example.com)\n                                      Date: Wed, 21 Aug 2019 17:22:50 +0000\n                                      Subject: this is a test\n                                      Message-ID: <abc123@example.com>\n\n                                      First message\n\n                                      From test3 at example.com  Wed Aug 21 17:42:50 2019\n                                      From: test3 at example.com (test3 at example.com)\n                                      Date: Wed, 21 Aug 2019 17:42:50 +0000\n                                      Subject: Re: this is a test\n                                      In-Reply-To: <def456@example.com>\n                                      Message-ID: <ghi789@example.com>\n\n                                      Third message\n\n                                      From test2 at example.com  Wed Aug 21 17:32:50 2019\n                                      From: test2 at example.com (test2 at example.com)\n                                      Date: Wed, 21 Aug 2019 17:32:50 +0000\n                                      Subject: Re: this is a test\n                                      In-Reply-To: <abc123@example.com> (This be a reply)\n                                      Message-ID: <def456@example.com>\n\n                                      Second message\n                                      \"\"\");\n            var mbox = MailingListServerFactory.createMboxFileServer(folder.path());\n            var list = mbox.getListReader(EmailAddress.parse(\"test@mbox\"));\n            var conversations = list.conversations(Duration.ofDays(365 * 100));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(3, conversation.allMessages().size());\n        }\n    }\n\n    @Test\n    void replyCross() throws IOException {\n        try (var folder = new TemporaryDirectory()) {\n            var rawMbox1 = folder.path().resolve(\"test1.mbox\");\n            Files.writeString(rawMbox1, \"\"\"\n                                      From test at example.com  Wed Aug 21 17:22:50 2019\n                                      From: test at example.com (test at example.com)\n                                      Date: Wed, 21 Aug 2019 17:22:50 +0000\n                                      Subject: this is a test\n                                      Message-ID: <abc123@example.com>\n\n                                      First message\n\n                                      From test2 at example.com  Wed Aug 21 17:32:50 2019\n                                      From: test2 at example.com (test2 at example.com)\n                                      Date: Wed, 21 Aug 2019 17:32:50 +0000\n                                      Subject: Re: this is a test\n                                      In-Reply-To: <abc123@example.com> (This be a reply)\n                                      Message-ID: <def456@example.com>\n\n                                      Second message\n\n                                      From test3 at example.com  Wed Aug 21 17:42:50 2019\n                                      From: test3 at example.com (test3 at example.com)\n                                      Date: Wed, 21 Aug 2019 17:42:50 +0000\n                                      Subject: Re: this is a test\n                                      In-Reply-To: <def456@example.com>\n                                      Message-ID: <ghi789@example.com>\n\n                                      Third message\n                                      \"\"\");\n            var rawMbox2 = folder.path().resolve(\"test2.mbox\");\n            Files.writeString(rawMbox2, \"\"\"\n                                      From test3 at example.com  Wed Aug 21 17:42:50 2019\n                                      From: test3 at example.com (test3 at example.com)\n                                      Date: Wed, 21 Aug 2019 17:42:50 +0000\n                                      Subject: Re: this is a test\n                                      In-Reply-To: <def456@example.com>\n                                      Message-ID: <ghi789@example.com>\n\n                                      Third message\n\n                                      From test2 at example.com  Wed Aug 21 17:32:50 2019\n                                      From: test2 at example.com (test2 at example.com)\n                                      Date: Wed, 21 Aug 2019 17:32:50 +0000\n                                      Subject: Re: this is a test\n                                      In-Reply-To: <abc123@example.com> (This be a reply)\n                                      Message-ID: <def456@example.com>\n\n                                      Second message\n                                      \"\"\");\n            var mbox = MailingListServerFactory.createMboxFileServer(folder.path());\n            var list = mbox.getListReader(EmailAddress.parse(\"test1@mbox\"), EmailAddress.parse(\"test2@mbox\"));\n            var conversations = list.conversations(Duration.ofDays(365 * 100));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(3, conversation.allMessages().size());\n        }\n    }\n\n    @Test\n    void replyOutOfOrderSplit() throws IOException {\n        try (var folder = new TemporaryDirectory()) {\n            var rawMbox1 = folder.path().resolve(\"test1.mbox\");\n            Files.writeString(rawMbox1, \"\"\"\n                                      From test at example.com  Wed Aug 21 17:22:50 2019\n                                      From: test at example.com (test at example.com)\n                                      Date: Wed, 21 Aug 2019 17:22:50 +0000\n                                      Subject: this is a test\n                                      Message-ID: <abc123@example.com>\n\n                                      First message\n\n                                      From test3 at example.com  Wed Aug 21 17:42:50 2019\n                                      From: test3 at example.com (test3 at example.com)\n                                      Date: Wed, 21 Aug 2019 17:42:50 +0000\n                                      Subject: Re: this is a test\n                                      In-Reply-To: <def456@example.com>\n                                      Message-ID: <ghi789@example.com>\n\n                                      Third message\n                                      \"\"\");\n            var rawMbox2 = folder.path().resolve(\"test2.mbox\");\n            Files.writeString(rawMbox2, \"\"\"\n                                      From test2 at example.com  Wed Aug 21 17:32:50 2019\n                                      From: test2 at example.com (test2 at example.com)\n                                      Date: Wed, 21 Aug 2019 17:32:50 +0000\n                                      Subject: Re: this is a test\n                                      In-Reply-To: <abc123@example.com> (This be a reply)\n                                      Message-ID: <def456@example.com>\n\n                                      Second message\n                                      \"\"\");\n            var mbox = MailingListServerFactory.createMboxFileServer(folder.path());\n            var list = mbox.getListReader(EmailAddress.parse(\"test1@mbox\"), EmailAddress.parse(\"test2@mbox\"));\n            var conversations = list.conversations(Duration.ofDays(365 * 100));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(3, conversation.allMessages().size());\n        }\n    }\n\n    /**\n     * Tests that fallback on References field works when In-Reply-To points to a non\n     * existing email.\n     */\n    @Test\n    void middleMessageMissing() throws IOException {\n        try (var folder = new TemporaryDirectory()) {\n            var rawMbox1 = folder.path().resolve(\"test1.mbox\");\n            Files.writeString(rawMbox1, \"\"\"\n                                      From test at example.com  Wed Aug 21 17:22:50 2019\n                                      From: test at example.com (test at example.com)\n                                      Date: Wed, 21 Aug 2019 17:22:50 +0000\n                                      Subject: this is a test\n                                      Message-ID: <abc123@example.com>\n\n                                      First message\n\n                                      From test3 at example.com  Wed Aug 21 17:42:50 2019\n                                      From: test3 at example.com (test3 at example.com)\n                                      Date: Wed, 21 Aug 2019 17:42:50 +0000\n                                      Subject: Re: this is a test\n                                      In-Reply-To: <def456@example.com>\n                                      References: <foo999@example.com>\n                                        <abc123@example.com>\n                                        <def456@example.com>\n                                      Message-ID: <ghi789@example.com>\n\n                                      Third message\n                                      \"\"\");\n            var mbox = MailingListServerFactory.createMboxFileServer(folder.path());\n            var list = mbox.getListReader(EmailAddress.parse(\"test1@mbox\"));\n            var conversations = list.conversations(Duration.ofDays(365 * 100));\n            assertEquals(1, conversations.size());\n            var conversation = conversations.get(0);\n            assertEquals(2, conversation.allMessages().size());\n        }\n    }\n}\n"
  },
  {
    "path": "metrics/build.gradle",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.metrics'\n    test {\n        requires 'org.junit.jupiter.api'\n        opens 'org.openjdk.skara.metrics' to 'org.junit.platform.commons'\n    }\n}\n\npublishing {\n    publications {\n        metrics(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "metrics/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.metrics {\n    requires java.management;\n    requires jdk.management;\n    exports org.openjdk.skara.metrics;\n}\n\n"
  },
  {
    "path": "metrics/src/main/java/org/openjdk/skara/metrics/Collector.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.metrics;\n\nimport java.util.List;\n\npublic interface Collector {\n    List<Metric> collect();\n}\n"
  },
  {
    "path": "metrics/src/main/java/org/openjdk/skara/metrics/CollectorRegistry.java",
    "content": "/*\n * Copyright (c) 2021, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.metrics;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.concurrent.ConcurrentLinkedQueue;\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.lang.management.ManagementFactory;\nimport java.lang.management.MemoryType;\nimport java.lang.management.MemoryUsage;\nimport com.sun.management.UnixOperatingSystemMXBean;\nimport com.sun.management.OperatingSystemMXBean;\n\npublic final class CollectorRegistry {\n    private static final CollectorRegistry DEFAULT = new CollectorRegistry(true, true);\n    private final ConcurrentLinkedQueue<Collector> collectors = new ConcurrentLinkedQueue<>();\n    private final boolean includeHotspotMetrics;\n    private final boolean includeProcessMetrics;\n\n    public CollectorRegistry(boolean includeHotspotMetrics, boolean includeProcessMetrics) {\n        this.includeHotspotMetrics = includeHotspotMetrics;\n        this.includeProcessMetrics = includeProcessMetrics;\n    }\n\n    public void register(Collector c) {\n        collectors.add(c);\n    }\n\n    public void unregister(Collector c) {\n        collectors.remove(c);\n    }\n\n    private static List<Metric> memoryUsageMetrics(String prefix, List<Metric.Label> labels, MemoryUsage usage) {\n        var result = new ArrayList<Metric>();\n        var max = usage.getMax();\n        if (max != -1) {\n            result.add(new Metric(Metric.Type.GAUGE, prefix + \"_max\", labels, max));\n        }\n        result.add(new Metric(Metric.Type.GAUGE, prefix + \"_used\", labels, usage.getUsed()));\n        result.add(new Metric(Metric.Type.GAUGE, prefix + \"_committed\", labels, usage.getCommitted()));\n        var init = usage.getInit();\n        if (init != -1) {\n            result.add(new Metric(Metric.Type.GAUGE, prefix + \"_init\", labels, init));\n        }\n        return result;\n    }\n\n    private static List<Metric> hotspotMetrics() {\n        var result = new ArrayList<Metric>();\n\n        var memoryMXBean = ManagementFactory.getMemoryMXBean();\n        var heapUsage = memoryMXBean.getHeapMemoryUsage();\n        var heapLabels = List.of(new Metric.Label(\"type\", MemoryType.HEAP.toString()));\n        result.addAll(memoryUsageMetrics(\"hotspot_memory\", heapLabels, heapUsage));\n\n        var nonHeapUsage = memoryMXBean.getNonHeapMemoryUsage();\n        var nonHeapLabels = List.of(new Metric.Label(\"type\", MemoryType.NON_HEAP.toString()));\n        result.addAll(memoryUsageMetrics(\"hotspot_memory\", nonHeapLabels, nonHeapUsage));\n\n        var numThreads = ManagementFactory.getThreadMXBean().getThreadCount();\n        result.add(new Metric(Metric.Type.GAUGE, \"hotspot_threads\", List.of(), numThreads));\n\n        var uptime = ManagementFactory.getRuntimeMXBean().getUptime();\n        result.add(new Metric(Metric.Type.COUNTER, \"hotspot_uptime\", List.of(), uptime));\n\n        for (var gcMXBean : ManagementFactory.getGarbageCollectorMXBeans()) {\n            var labels = List.of(new Metric.Label(\"gc_name\", gcMXBean.getName()));\n\n            var gcCount = gcMXBean.getCollectionCount();\n            result.add(new Metric(Metric.Type.COUNTER, \"hotspot_gc_count\", labels, gcCount));\n\n            var gcTime = gcMXBean.getCollectionTime() / 1000.0;\n            result.add(new Metric(Metric.Type.COUNTER, \"hotspot_gc_time\", labels, gcTime));\n        }\n\n        for (var memoryPoolMXBean : ManagementFactory.getMemoryPoolMXBeans()) {\n            var labels = List.of(new Metric.Label(\"memory_pool_name\", memoryPoolMXBean.getName()),\n                                 new Metric.Label(\"memory_pool_type\", memoryPoolMXBean.getType().toString()));\n            var usage = memoryPoolMXBean.getUsage();\n            result.addAll(memoryUsageMetrics(\"hotspot_memory_pool\", labels, usage));\n        }\n\n        var compilationMXBean = ManagementFactory.getCompilationMXBean();\n        if (compilationMXBean.isCompilationTimeMonitoringSupported()) {\n            var compilationTime = ManagementFactory.getCompilationMXBean().getTotalCompilationTime();\n            result.add(new Metric(Metric.Type.COUNTER, \"hotspot_compilation_time\", List.of(), compilationTime));\n        }\n\n        var classLoadingMXBean = ManagementFactory.getClassLoadingMXBean();\n        var totalLoadedClasses = classLoadingMXBean.getTotalLoadedClassCount();\n        result.add(new Metric(Metric.Type.COUNTER, \"hotspot_classes_loaded\", List.of(), totalLoadedClasses));\n        var totalUnloadedClasses = classLoadingMXBean.getTotalLoadedClassCount();\n        result.add(new Metric(Metric.Type.COUNTER, \"hotspot_classes_unloaded\", List.of(), totalUnloadedClasses));\n\n        return result;\n    }\n\n    private static List<Metric> processMetrics() {\n        var result = new ArrayList<Metric>();\n        if (System.getProperty(\"os.name\").toLowerCase().startsWith(\"linux\")) {\n            List<String> status = List.of();\n            try {\n                status = Files.readAllLines(Path.of(\"/proc/self/status\"));\n            } catch (IOException e) {\n                // ignore\n            }\n            for (var line : status) {\n                if (line.startsWith(\"VmRSS:\")) {\n                    var parts = line.split(\"\\\\s+\");\n                    if (parts.length == 3) {\n                        var rssInKb = Long.parseLong(parts[1]);\n                        var rssBytes = rssInKb * 1024;\n                        result.add(new Metric(Metric.Type.GAUGE, \"process_resident_memory_bytes\", List.of(), rssBytes));\n                    }\n                } else if (line.startsWith(\"VmSize:\")) {\n                    var parts = line.split(\"\\\\s+\");\n                    if (parts.length == 3) {\n                        var vmSizeInKb = Long.parseLong(parts[1]);\n                        var vmBytes = vmSizeInKb * 1024;\n                        result.add(new Metric(Metric.Type.GAUGE, \"process_virtual_memory_bytes\", List.of(), vmBytes));\n                    }\n                }\n            }\n\n            List<String> maps = List.of();\n            try {\n                maps = Files.readAllLines(Path.of(\"/proc/self/maps\"));\n            } catch (IOException e) {\n                // ignore\n            }\n            var heapBytes = maps.stream()\n                                .filter(l -> l.endsWith(\"[heap]\"))\n                                .map(l -> l.split(\"\\\\s+\")[0])\n                                .mapToLong(range -> {\n                                    var parts = range.split(\"-\");\n                                    var start = Long.parseLong(parts[0], 16);\n                                    var end = Long.parseLong(parts[1], 16);\n                                    return end - start;\n                                })\n                                .sum();\n            result.add(new Metric(Metric.Type.GAUGE, \"process_heap_bytes\", List.of(), heapBytes));\n        }\n\n        var bean = ManagementFactory.getOperatingSystemMXBean();\n        if (bean instanceof UnixOperatingSystemMXBean osBean) {\n            var numOpenFds = osBean.getOpenFileDescriptorCount();\n            result.add(new Metric(Metric.Type.GAUGE, \"process_open_fds\", List.of(), numOpenFds));\n            var maxFds = osBean.getMaxFileDescriptorCount();\n            result.add(new Metric(Metric.Type.GAUGE, \"process_max_fds\", List.of(), maxFds));\n        }\n\n        if (bean instanceof OperatingSystemMXBean osBean) {\n            var vmMaxBytes = osBean.getCommittedVirtualMemorySize();\n            result.add(new Metric(Metric.Type.GAUGE, \"process_virtual_memory_max_bytes\", List.of(), vmMaxBytes));\n\n            var cpuTimeNs = osBean.getProcessCpuTime();\n            var cpuTimeSec = cpuTimeNs / 1_000_000_000.0;\n            result.add(new Metric(Metric.Type.COUNTER, \"process_cpu_seconds_total\", List.of(), cpuTimeSec));\n        }\n\n        var startTimeMillis = ManagementFactory.getRuntimeMXBean().getStartTime();\n        result.add(new Metric(Metric.Type.COUNTER, \"process_start_time_seconds\", List.of(), startTimeMillis / 1000.0));\n\n        return result;\n    }\n\n    public List<Metric> scrape() {\n        var result = new ArrayList<Metric>();\n        for (var collector : collectors) {\n            result.addAll(collector.collect());\n        }\n        if (includeHotspotMetrics) {\n            result.addAll(hotspotMetrics());\n        }\n        if (includeProcessMetrics) {\n            result.addAll(processMetrics());\n        }\n        return result;\n    }\n\n    public static CollectorRegistry defaultRegistry() {\n        return DEFAULT;\n    }\n}\n"
  },
  {
    "path": "metrics/src/main/java/org/openjdk/skara/metrics/Counter.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.metrics;\n\nimport java.util.*;\nimport java.util.concurrent.atomic.DoubleAdder;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic final class Counter implements Collector {\n    public final static class Builder {\n        public final static class WithOneLabel {\n            private final String name;\n            private final String label;\n\n            WithOneLabel(String name, String label) {\n                this.name = name;\n                this.label = label;\n            }\n\n            public Counter.WithOneLabel register() {\n                return register(CollectorRegistry.defaultRegistry());\n            }\n\n            public Counter.WithOneLabel register(CollectorRegistry registry) {\n                var counter = new Counter.WithOneLabel(name, label);\n                registry.register(counter);\n                return counter;\n            }\n        }\n\n        public final static class WithTwoLabels {\n            private final String name;\n            private final String label1;\n            private final String label2;\n\n            WithTwoLabels(String name, String label1, String label2) {\n                this.name = name;\n                this.label1 = label1;\n                this.label2 = label2;\n            }\n\n            public Counter.WithTwoLabels register() {\n                return register(CollectorRegistry.defaultRegistry());\n            }\n\n            public Counter.WithTwoLabels register(CollectorRegistry registry) {\n                var counter = new Counter.WithTwoLabels(name, label1, label2);\n                registry.register(counter);\n                return counter;\n            }\n        }\n\n        public final static class WithThreeLabels {\n            private final String name;\n            private final String label1;\n            private final String label2;\n            private final String label3;\n\n            WithThreeLabels(String name, String label1, String label2, String label3) {\n                this.name = name;\n                this.label1 = label1;\n                this.label2 = label2;\n                this.label3 = label3;\n            }\n\n            public Counter.WithThreeLabels register() {\n                return register(CollectorRegistry.defaultRegistry());\n            }\n\n            public Counter.WithThreeLabels register(CollectorRegistry registry) {\n                var counter = new Counter.WithThreeLabels(name, label1, label2, label3);\n                registry.register(counter);\n                return counter;\n            }\n        }\n\n        private final String name;\n\n        Builder(String name) {\n            this.name = name;\n        }\n\n        public Counter register() {\n            return register(CollectorRegistry.defaultRegistry());\n        }\n\n        public Counter register(CollectorRegistry registry) {\n            var counter = new Counter(name);\n            registry.register(counter);\n            return counter;\n        }\n\n        public Builder.WithOneLabel labels(String label) {\n            return new Builder.WithOneLabel(name, label);\n        }\n\n        public Builder.WithTwoLabels labels(String label1, String label2) {\n            return new Builder.WithTwoLabels(name, label1, label2);\n        }\n\n        public Builder.WithThreeLabels labels(String label1, String label2, String label3) {\n            return new Builder.WithThreeLabels(name, label1, label2, label3);\n        }\n    }\n\n    public static final class Incrementer {\n        private final DoubleAdder adder;\n        private final Runnable resetter;\n\n        Incrementer(DoubleAdder adder, Runnable resetter) {\n            this.adder = adder;\n            this.resetter = resetter;\n        }\n\n        public void inc() {\n            inc(1);\n        }\n\n        public void inc(double d) {\n            adder.add(d);\n        }\n\n        public void reset() {\n            resetter.run();\n        }\n    }\n\n    private final String name;\n    private volatile DoubleAdder value;\n\n    Counter(String name) {\n        this.name = name;\n        this.value = new DoubleAdder();\n    }\n\n    public static Counter.Builder name(String name) {\n        return new Counter.Builder(name);\n    }\n\n    public void inc() {\n        inc(1);\n    }\n\n    public void inc(double d) {\n        value.add(d);\n    }\n\n    public void reset() {\n        value = new DoubleAdder();\n    }\n\n    @Override\n    public List<Metric> collect() {\n        return List.of(new Metric(Metric.Type.COUNTER, name, List.of(), value.sum()));\n    }\n\n    public static final class WithOneLabel implements Collector {\n        private final String name;\n        private final String label;\n        private final ConcurrentHashMap<String, DoubleAdder> value;\n\n        public WithOneLabel(String name, String label) {\n            this.name = name;\n            this.label = label;\n            this.value = new ConcurrentHashMap<>();\n        }\n\n        public Incrementer labels(String labelValue) {\n            var adder = new DoubleAdder();\n            var existing = value.putIfAbsent(labelValue, adder);\n            if (existing == null) {\n                existing = adder;\n            }\n            return new Incrementer(existing, () -> {\n                value.put(labelValue, new DoubleAdder());\n            });\n        }\n\n        @Override\n        public List<Metric> collect() {\n            var metrics = new ArrayList<Metric>();\n            for (var key : value.keySet()) {\n                var l = new Metric.Label(label, key);\n                var d = value.get(key);\n                metrics.add(new Metric(Metric.Type.COUNTER, name, List.of(l), d.sum()));\n            }\n            return metrics;\n        }\n    }\n\n    public static final class WithTwoLabels implements Collector {\n        private final String name;\n        private final String label1;\n        private final String label2;\n        private final ConcurrentHashMap<List<String>, DoubleAdder> value;\n\n        public WithTwoLabels(String name, String label1, String label2) {\n            this.name = name;\n            this.label1 = label1;\n            this.label2 = label2;\n            this.value = new ConcurrentHashMap<>();\n        }\n\n        public Incrementer labels(String labelValue1, String labelValue2) {\n            var adder = new DoubleAdder();\n            var key = List.of(labelValue1, labelValue2);\n            var existing = value.putIfAbsent(key, adder);\n            if (existing == null) {\n                existing = adder;\n            }\n            return new Incrementer(existing, () -> {\n                value.put(key, new DoubleAdder());\n            });\n        }\n\n        @Override\n        public List<Metric> collect() {\n            var metrics = new ArrayList<Metric>();\n            for (var values : value.keySet()) {\n                var labels =\n                    List.of(new Metric.Label(label1, values.get(0)),\n                            new Metric.Label(label2, values.get(1)));\n                var d = value.get(values);\n                metrics.add(new Metric(Metric.Type.COUNTER, name, labels, d.sum()));\n            }\n            return metrics;\n        }\n    }\n\n    public static final class WithThreeLabels implements Collector {\n        private final String name;\n        private final String label1;\n        private final String label2;\n        private final String label3;\n        private final ConcurrentHashMap<List<String>, DoubleAdder> value;\n\n        public WithThreeLabels(String name, String label1, String label2, String label3) {\n            this.name = name;\n            this.label1 = label1;\n            this.label2 = label2;\n            this.label3 = label3;\n            this.value = new ConcurrentHashMap<>();\n        }\n\n        public Incrementer labels(String labelValue1, String labelValue2, String labelValue3) {\n            var adder = new DoubleAdder();\n            var key = List.of(labelValue1, labelValue2, labelValue3);\n            var existing = value.putIfAbsent(key, adder);\n            if (existing == null) {\n                existing = adder;\n            }\n            return new Incrementer(existing, () -> {\n                value.put(key, new DoubleAdder());\n            });\n        }\n\n        @Override\n        public List<Metric> collect() {\n            var metrics = new ArrayList<Metric>();\n            for (var values : value.keySet()) {\n                var labels =\n                    List.of(new Metric.Label(label1, values.get(0)),\n                            new Metric.Label(label2, values.get(1)),\n                            new Metric.Label(label3, values.get(2)));\n                var d = value.get(values);\n                metrics.add(new Metric(Metric.Type.COUNTER, name, labels, d.sum()));\n            }\n            return metrics;\n        }\n    }\n}\n"
  },
  {
    "path": "metrics/src/main/java/org/openjdk/skara/metrics/Exporter.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.metrics;\n\nimport java.util.List;\n\npublic interface Exporter {\n    String export(List<Metric> metrics);\n}\n"
  },
  {
    "path": "metrics/src/main/java/org/openjdk/skara/metrics/Gauge.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.metrics;\n\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.function.Consumer;\nimport java.util.concurrent.atomic.DoubleAdder;\nimport java.util.concurrent.ConcurrentHashMap;\n\npublic final class Gauge implements Collector {\n    public final static class Builder {\n        public final static class WithOneLabel {\n            private final String name;\n            private final String label;\n\n            WithOneLabel(String name, String label) {\n                this.name = name;\n                this.label = label;\n            }\n\n            public Gauge.WithOneLabel register() {\n                return register(CollectorRegistry.defaultRegistry());\n            }\n\n            public Gauge.WithOneLabel register(CollectorRegistry registry) {\n                var gauge = new Gauge.WithOneLabel(name, label);\n                registry.register(gauge);\n                return gauge;\n            }\n        }\n\n        public final static class WithTwoLabels {\n            private final String name;\n            private final String label1;\n            private final String label2;\n\n            WithTwoLabels(String name, String label1, String label2) {\n                this.name = name;\n                this.label1 = label1;\n                this.label2 = label2;\n            }\n\n            public Gauge.WithTwoLabels register() {\n                return register(CollectorRegistry.defaultRegistry());\n            }\n\n            public Gauge.WithTwoLabels register(CollectorRegistry registry) {\n                var gauge = new Gauge.WithTwoLabels(name, label1, label2);\n                registry.register(gauge);\n                return gauge;\n            }\n        }\n\n        public final static class WithThreeLabels {\n            private final String name;\n            private final String label1;\n            private final String label2;\n            private final String label3;\n\n            WithThreeLabels(String name, String label1, String label2, String label3) {\n                this.name = name;\n                this.label1 = label1;\n                this.label2 = label2;\n                this.label3 = label3;\n            }\n\n            public Gauge.WithThreeLabels register() {\n                return register(CollectorRegistry.defaultRegistry());\n            }\n\n            public Gauge.WithThreeLabels register(CollectorRegistry registry) {\n                var gauge = new Gauge.WithThreeLabels(name, label1, label2, label3);\n                registry.register(gauge);\n                return gauge;\n            }\n        }\n\n        private final String name;\n\n        Builder(String name) {\n            this.name = name;\n        }\n\n        public Gauge register() {\n            return register(CollectorRegistry.defaultRegistry());\n        }\n\n        public Gauge register(CollectorRegistry registry) {\n            var gauge = new Gauge(name);\n            registry.register(gauge);\n            return gauge;\n        }\n\n        public Builder.WithOneLabel labels(String label) {\n            return new Builder.WithOneLabel(name, label);\n        }\n\n        public Builder.WithTwoLabels labels(String label1, String label2) {\n            return new Builder.WithTwoLabels(name, label1, label2);\n        }\n\n        public Builder.WithThreeLabels labels(String label1, String label2, String label3) {\n            return new Builder.WithThreeLabels(name, label1, label2, label3);\n        }\n    }\n\n    public static final class Adjuster {\n        private final DoubleAdder adder;\n        private final Consumer<Double> resetter;\n\n        Adjuster(DoubleAdder adder, Consumer<Double> resetter) {\n            this.adder = adder;\n            this.resetter = resetter;\n        }\n\n        public void inc() {\n            inc(1);\n        }\n\n        public void inc(double d) {\n            adder.add(d);\n        }\n\n        public void dec() {\n            inc(-1);\n        }\n\n        public void dec(double d) {\n            adder.add(0 - d);\n        }\n\n        public void set(double d) {\n            resetter.accept(d);\n        }\n\n        public void setToCurrentTime() {\n            set(ZonedDateTime.now().toInstant().toEpochMilli() / 1000.0);\n        }\n    }\n\n    private final String name;\n    private volatile DoubleAdder value;\n\n    Gauge(String name) {\n        this.name = name;\n        this.value = new DoubleAdder();\n    }\n\n    public static Gauge.Builder name(String name) {\n        return new Gauge.Builder(name);\n    }\n\n    public void inc() {\n        inc(1);\n    }\n\n    public void inc(double d) {\n        value.add(d);\n    }\n\n    public void dec() {\n        inc(-1);\n    }\n\n    public void dec(double d) {\n        value.add(0 - d);\n    }\n\n    public void set(double d) {\n        var newAdder = new DoubleAdder();\n        newAdder.add(d);\n        value = newAdder;\n    }\n\n    public void setToCurrentTime() {\n        set(ZonedDateTime.now().toInstant().toEpochMilli() / 1000.0);\n    }\n\n    @Override\n    public List<Metric> collect() {\n        return List.of(new Metric(Metric.Type.GAUGE, name, List.of(), value.sum()));\n    }\n\n    public static final class WithOneLabel implements Collector {\n        private final String name;\n        private final String label;\n        private final ConcurrentHashMap<String, DoubleAdder> value;\n\n        public WithOneLabel(String name, String label) {\n            this.name = name;\n            this.label = label;\n            this.value = new ConcurrentHashMap<>();\n        }\n\n        public Adjuster labels(String labelValue) {\n            var adder = new DoubleAdder();\n            var existing = value.putIfAbsent(labelValue, adder);\n            if (existing == null) {\n                existing = adder;\n            }\n            return new Adjuster(existing, (v) -> {\n                var newAdder = new DoubleAdder();\n                newAdder.add(v);\n                value.put(labelValue, newAdder);\n            });\n        }\n\n        @Override\n        public List<Metric> collect() {\n            var metrics = new ArrayList<Metric>();\n            for (var key : value.keySet()) {\n                var l = new Metric.Label(label, key);\n                var d = value.get(key);\n                metrics.add(new Metric(Metric.Type.GAUGE, name, List.of(l), d.sum()));\n            }\n            return metrics;\n        }\n    }\n\n    public static final class WithTwoLabels implements Collector {\n        private final String name;\n        private final String label1;\n        private final String label2;\n        private final ConcurrentHashMap<List<String>, DoubleAdder> value;\n\n        public WithTwoLabels(String name, String label1, String label2) {\n            this.name = name;\n            this.label1 = label1;\n            this.label2 = label2;\n            this.value = new ConcurrentHashMap<>();\n        }\n\n        public Adjuster labels(String labelValue1, String labelValue2) {\n            var adder = new DoubleAdder();\n            var key = List.of(labelValue1, labelValue2);\n            var existing = value.putIfAbsent(key, adder);\n            if (existing == null) {\n                existing = adder;\n            }\n            return new Adjuster(existing, (v) -> {\n                var newAdder = new DoubleAdder();\n                newAdder.add(v);\n                value.put(key, newAdder);\n            });\n        }\n\n        @Override\n        public List<Metric> collect() {\n            var metrics = new ArrayList<Metric>();\n            for (var values : value.keySet()) {\n                var labels =\n                    List.of(new Metric.Label(label1, values.get(0)),\n                            new Metric.Label(label2, values.get(1)));\n                var d = value.get(values);\n                metrics.add(new Metric(Metric.Type.GAUGE, name, labels, d.sum()));\n            }\n            return metrics;\n        }\n    }\n\n    public static final class WithThreeLabels implements Collector {\n        private final String name;\n        private final String label1;\n        private final String label2;\n        private final String label3;\n        private final ConcurrentHashMap<List<String>, DoubleAdder> value;\n\n        public WithThreeLabels(String name, String label1, String label2, String label3) {\n            this.name = name;\n            this.label1 = label1;\n            this.label2 = label2;\n            this.label3 = label3;\n            this.value = new ConcurrentHashMap<>();\n        }\n\n        public Adjuster labels(String labelValue1, String labelValue2, String labelValue3) {\n            var adder = new DoubleAdder();\n            var key = List.of(labelValue1, labelValue2, labelValue3);\n            var existing = value.putIfAbsent(key, adder);\n            if (existing == null) {\n                existing = adder;\n            }\n            return new Adjuster(existing, (v) -> {\n                var newAdder = new DoubleAdder();\n                newAdder.add(v);\n                value.put(key, newAdder);\n            });\n        }\n\n        @Override\n        public List<Metric> collect() {\n            var metrics = new ArrayList<Metric>();\n            for (var values : value.keySet()) {\n                var labels =\n                    List.of(new Metric.Label(label1, values.get(0)),\n                            new Metric.Label(label2, values.get(1)),\n                            new Metric.Label(label3, values.get(2)));\n                var d = value.get(values);\n                metrics.add(new Metric(Metric.Type.GAUGE, name, labels, d.sum()));\n            }\n            return metrics;\n        }\n    }\n}\n"
  },
  {
    "path": "metrics/src/main/java/org/openjdk/skara/metrics/Metric.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.metrics;\n\nimport java.util.List;\n\npublic final class Metric {\n    public final static class Label {\n        private String name;\n        private String value;\n\n        public Label(String name, String value) {\n            this.name = name;\n            this.value = value;\n        }\n\n        public String name() {\n            return name;\n        }\n\n        public String value() {\n            return value;\n        }\n    }\n\n    public enum Type {\n        COUNTER,\n        GAUGE,\n        HISTOGRAM,\n        SUMMARY;\n\n        @Override\n        public String toString() {\n            switch (this) {\n                case COUNTER:\n                    return \"counter\";\n                case GAUGE:\n                    return \"gauge\";\n                case HISTOGRAM:\n                    return \"histogram\";\n                case SUMMARY:\n                    return \"summary\";\n                default:\n                    throw new IllegalStateException(\"Unexpected type\");\n            }\n        }\n    }\n\n    private final Type type;\n    private final String name;\n    private final List<Label> labels;\n    private final double value;\n\n    public Metric(Type type, String name, List<Label> labels, double value) {\n        this.type = type;\n        this.name = name;\n        this.labels = labels;\n        this.value = value;\n    }\n\n    public Type type() {\n        return type;\n    }\n\n    public String name() {\n        return name;\n    }\n\n    public List<Label> labels() {\n        return labels;\n    }\n\n    public double value() {\n        return value;\n    }\n}\n"
  },
  {
    "path": "metrics/src/main/java/org/openjdk/skara/metrics/PrometheusExporter.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.metrics;\n\nimport java.util.*;\n\npublic class PrometheusExporter implements Exporter {\n    @Override\n    public String export(List<Metric> metrics) {\n        var typed = new HashSet<String>();\n        var sb = new StringBuilder();\n        for (var metric : metrics) {\n            if (!typed.contains(metric.name())) {\n                sb.append(\"# TYPE \");\n                sb.append(metric.name());\n                sb.append(\" \");\n                sb.append(metric.type().toString());\n                sb.append(\"\\n\");\n\n                typed.add(metric.name());\n            }\n            sb.append(metric.name());\n            var labels = metric.labels();\n            if (!labels.isEmpty()) {\n                sb.append(\"{\");\n                for (var i = 0; i < labels.size(); i++) {\n                    var label = labels.get(i);\n                    sb.append(label.name());\n                    sb.append(\"=\\\"\");\n                    sb.append(label.value());\n                    sb.append(\"\\\"\");\n                    if (i != labels.size() - 1) {\n                        sb.append(\",\");\n                    }\n                }\n                sb.append(\"}\");\n            }\n            sb.append(\" \");\n            sb.append(Double.toString(metric.value()));\n            sb.append(\"\\n\");\n        }\n        return sb.toString();\n    }\n}\n"
  },
  {
    "path": "metrics/src/test/java/org/openjdk/skara/metrics/CollectorRegistryTests.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.metrics;\n\nimport org.junit.jupiter.api.*;\n\nimport java.util.*;\nimport java.util.stream.Collectors;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass CollectorRegistryTests {\n    @Test\n    void register() {\n        var registry = new CollectorRegistry(false, false);\n        var counter = Counter.name(\"counter\").register(registry);\n        var gauge = Gauge.name(\"gauge\").register(registry);\n        var metrics = registry.scrape();\n        assertEquals(2, metrics.size());\n        assertEquals(\"counter\", metrics.get(0).name());\n        assertEquals(Metric.Type.COUNTER, metrics.get(0).type());\n        assertEquals(\"gauge\", metrics.get(1).name());\n        assertEquals(Metric.Type.GAUGE, metrics.get(1).type());\n    }\n\n    @Test\n    void unregister() {\n        var registry = new CollectorRegistry(false, false);\n        var counter = Counter.name(\"test\").register(registry);\n        assertEquals(1, registry.scrape().size());\n        registry.unregister(counter);\n        assertEquals(0, registry.scrape().size());\n    }\n\n    @Test\n    void hotspotMetrics() {\n        var registry = new CollectorRegistry(true, false);\n        var metrics = registry.scrape();\n        var metricNames = metrics.stream().map(Metric::name).collect(Collectors.toSet());\n        assertTrue(metricNames.contains(\"hotspot_memory_max\"));\n        assertTrue(metricNames.contains(\"hotspot_memory_used\"));\n        assertTrue(metricNames.contains(\"hotspot_memory_committed\"));\n        assertTrue(metricNames.contains(\"hotspot_memory_init\"));\n        assertTrue(metricNames.contains(\"hotspot_threads\"));\n        assertTrue(metricNames.contains(\"hotspot_uptime\"));\n        assertTrue(metricNames.contains(\"hotspot_gc_count\"));\n        assertTrue(metricNames.contains(\"hotspot_gc_time\"));\n        assertTrue(metricNames.contains(\"hotspot_memory_pool_max\"));\n        assertTrue(metricNames.contains(\"hotspot_memory_pool_used\"));\n        assertTrue(metricNames.contains(\"hotspot_memory_pool_committed\"));\n        assertTrue(metricNames.contains(\"hotspot_memory_pool_init\"));\n        assertTrue(metricNames.contains(\"hotspot_classes_loaded\"));\n        assertTrue(metricNames.contains(\"hotspot_classes_unloaded\"));\n    }\n\n    @Test\n    void processMetrics() {\n        var registry = new CollectorRegistry(false, true);\n        var metrics = registry.scrape();\n        var metricNames = metrics.stream().map(Metric::name).collect(Collectors.toSet());\n        assertTrue(metricNames.contains(\"process_start_time_seconds\"));\n    }\n}\n"
  },
  {
    "path": "metrics/src/test/java/org/openjdk/skara/metrics/CounterTests.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.metrics;\n\nimport org.junit.jupiter.api.*;\nimport java.util.*;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass CounterTests {\n    @Test\n    void inc() {\n        var registry = new CollectorRegistry(false, false);\n        var counter = Counter.name(\"test\").register(registry);\n        assertEquals(0, counter.collect().get(0).value());\n        counter.inc();\n        assertEquals(1, counter.collect().get(0).value());\n    }\n\n    @Test\n    void incTwice() {\n        var registry = new CollectorRegistry(false, false);\n        var counter = Counter.name(\"test\").register(registry);\n        assertEquals(0, counter.collect().get(0).value());\n        counter.inc();\n        assertEquals(1, counter.collect().get(0).value());\n        counter.inc();\n        assertEquals(2, counter.collect().get(0).value());\n        counter.inc();\n        counter.inc();\n        assertEquals(4, counter.collect().get(0).value());\n    }\n\n    @Test\n    void incWithValue() {\n        var registry = new CollectorRegistry(false, false);\n        var counter = Counter.name(\"test\").register(registry);\n        assertEquals(0, counter.collect().get(0).value());\n        counter.inc(17);\n        assertEquals(17, counter.collect().get(0).value());\n    }\n\n    @Test\n    void incWithValueMixedWithInc() {\n        var registry = new CollectorRegistry(false, false);\n        var counter = Counter.name(\"test\").register(registry);\n        assertEquals(0, counter.collect().get(0).value());\n        counter.inc(17);\n        assertEquals(17, counter.collect().get(0).value());\n        counter.inc();\n        assertEquals(18, counter.collect().get(0).value());\n        counter.inc(3);\n        assertEquals(21, counter.collect().get(0).value());\n    }\n\n    @Test\n    void incAndReset() {\n        var registry = new CollectorRegistry(false, false);\n        var counter = Counter.name(\"test\").register(registry);\n        assertEquals(0, counter.collect().get(0).value());\n        counter.inc(17);\n        assertEquals(17, counter.collect().get(0).value());\n        counter.reset();\n        assertEquals(0, counter.collect().get(0).value());\n        counter.inc(3);\n        assertEquals(3, counter.collect().get(0).value());\n    }\n\n    @Test\n    void oneLabel() {\n        var registry = new CollectorRegistry(false, false);\n        var counter = Counter.name(\"test\").labels(\"a\").register(registry);\n        counter.labels(\"1\").inc(17);\n        assertEquals(1, counter.collect().size());\n        assertEquals(17, counter.collect().get(0).value());\n        assertEquals(1, counter.collect().get(0).labels().size());\n        assertEquals(\"a\", counter.collect().get(0).labels().get(0).name());\n        assertEquals(\"1\", counter.collect().get(0).labels().get(0).value());\n    }\n\n    @Test\n    void twoLabels() {\n        var registry = new CollectorRegistry(false, false);\n        var counter = Counter.name(\"test\").labels(\"a\", \"b\").register(registry);\n        counter.labels(\"1\", \"2\").inc(17);\n        assertEquals(1, counter.collect().size());\n        assertEquals(17, counter.collect().get(0).value());\n        assertEquals(2, counter.collect().get(0).labels().size());\n        assertEquals(\"a\", counter.collect().get(0).labels().get(0).name());\n        assertEquals(\"1\", counter.collect().get(0).labels().get(0).value());\n        assertEquals(\"b\", counter.collect().get(0).labels().get(1).name());\n        assertEquals(\"2\", counter.collect().get(0).labels().get(1).value());\n    }\n\n    @Test\n    void threeLabels() {\n        var registry = new CollectorRegistry(false, false);\n        var counter = Counter.name(\"test\").labels(\"a\", \"b\", \"c\").register(registry);\n        counter.labels(\"1\", \"2\", \"3\").inc(17);\n        assertEquals(1, counter.collect().size());\n        assertEquals(17, counter.collect().get(0).value());\n        assertEquals(3, counter.collect().get(0).labels().size());\n        assertEquals(\"a\", counter.collect().get(0).labels().get(0).name());\n        assertEquals(\"1\", counter.collect().get(0).labels().get(0).value());\n        assertEquals(\"b\", counter.collect().get(0).labels().get(1).name());\n        assertEquals(\"2\", counter.collect().get(0).labels().get(1).value());\n        assertEquals(\"c\", counter.collect().get(0).labels().get(2).name());\n        assertEquals(\"3\", counter.collect().get(0).labels().get(2).value());\n    }\n\n    @Test\n    void threeLabelsIncAndReset() {\n        var registry = new CollectorRegistry(false, false);\n        var counter = Counter.name(\"test\").labels(\"a\", \"b\", \"c\").register(registry);\n        counter.labels(\"1\", \"2\", \"3\").inc();\n        assertEquals(1, counter.collect().get(0).value());\n        counter.labels(\"1\", \"2\", \"3\").inc(17);\n        assertEquals(18, counter.collect().get(0).value());\n        counter.labels(\"1\", \"2\", \"3\").reset();\n        assertEquals(0, counter.collect().get(0).value());\n        counter.labels(\"1\", \"2\", \"3\").inc(37);\n        assertEquals(37, counter.collect().get(0).value());\n    }\n\n    @Test\n    void oneLabelMultiple() {\n        var registry = new CollectorRegistry(false, false);\n        var counter = Counter.name(\"test\").labels(\"a\").register(registry);\n        counter.labels(\"1\").inc(17);\n        counter.labels(\"2\").inc(19);\n        assertEquals(2, counter.collect().size());\n        var values = counter.collect().stream().map(l -> l.value()).toList();\n        assertTrue(values.contains(17.0));\n        assertTrue(values.contains(19.0));\n    }\n}\n"
  },
  {
    "path": "metrics/src/test/java/org/openjdk/skara/metrics/GaugeTests.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.metrics;\n\nimport org.junit.jupiter.api.*;\nimport java.util.*;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass GaugeTests {\n    @Test\n    void inc() {\n        var registry = new CollectorRegistry(false, false);\n        var gauge = Gauge.name(\"test\").register(registry);\n        gauge.inc();\n        assertEquals(1, gauge.collect().get(0).value());\n    }\n\n    @Test\n    void dec() {\n        var registry = new CollectorRegistry(false, false);\n        var gauge = Gauge.name(\"test\").register(registry);\n        gauge.inc();\n        assertEquals(1, gauge.collect().get(0).value());\n        gauge.dec();\n        assertEquals(0, gauge.collect().get(0).value());\n        gauge.dec();\n        assertEquals(-1, gauge.collect().get(0).value());\n    }\n\n    @Test\n    void incWithValue() {\n        var registry = new CollectorRegistry(false, false);\n        var gauge = Gauge.name(\"test\").register(registry);\n        gauge.inc(17);\n        assertEquals(17, gauge.collect().get(0).value());\n    }\n\n    @Test\n    void decWithValue() {\n        var registry = new CollectorRegistry(false, false);\n        var gauge = Gauge.name(\"test\").register(registry);\n        gauge.dec(17);\n        assertEquals(-17, gauge.collect().get(0).value());\n    }\n\n    @Test\n    void incAndDecWithValue() {\n        var registry = new CollectorRegistry(false, false);\n        var gauge = Gauge.name(\"test\").register(registry);\n        gauge.inc(17);\n        assertEquals(17, gauge.collect().get(0).value());\n        gauge.dec(20);\n        assertEquals(-3, gauge.collect().get(0).value());\n    }\n\n    @Test\n    void set() {\n        var registry = new CollectorRegistry(false, false);\n        var gauge = Gauge.name(\"test\").register(registry);\n        gauge.set(1337);\n        assertEquals(1337, gauge.collect().get(0).value());\n        gauge.set(17);\n        assertEquals(17, gauge.collect().get(0).value());\n    }\n\n    @Test\n    void oneLabel() {\n        var registry = new CollectorRegistry(false, false);\n        var gauge = Gauge.name(\"test\").labels(\"a\").register(registry);\n        gauge.labels(\"1\").inc();\n        assertEquals(1, gauge.collect().size());\n        assertEquals(1, gauge.collect().get(0).value());\n        assertEquals(1, gauge.collect().get(0).labels().size());\n        assertEquals(\"a\", gauge.collect().get(0).labels().get(0).name());\n        assertEquals(\"1\", gauge.collect().get(0).labels().get(0).value());\n    }\n\n    @Test\n    void oneLabelIncDecSet() {\n        var registry = new CollectorRegistry(false, false);\n        var gauge = Gauge.name(\"test\").labels(\"a\").register(registry);\n        gauge.labels(\"1\").inc(17);\n        assertEquals(1, gauge.collect().size());\n        assertEquals(17, gauge.collect().get(0).value());\n        assertEquals(1, gauge.collect().get(0).labels().size());\n        assertEquals(\"a\", gauge.collect().get(0).labels().get(0).name());\n        assertEquals(\"1\", gauge.collect().get(0).labels().get(0).value());\n        gauge.labels(\"1\").dec(20);\n        assertEquals(-3, gauge.collect().get(0).value());\n        gauge.labels(\"1\").set(1337);\n        assertEquals(1337, gauge.collect().get(0).value());\n    }\n\n    @Test\n    void twoLabels() {\n        var registry = new CollectorRegistry(false, false);\n        var gauge = Gauge.name(\"test\").labels(\"a\", \"b\").register(registry);\n        gauge.labels(\"1\", \"2\").inc();\n        assertEquals(1, gauge.collect().size());\n        assertEquals(1, gauge.collect().get(0).value());\n        assertEquals(2, gauge.collect().get(0).labels().size());\n        assertEquals(\"a\", gauge.collect().get(0).labels().get(0).name());\n        assertEquals(\"1\", gauge.collect().get(0).labels().get(0).value());\n        assertEquals(\"b\", gauge.collect().get(0).labels().get(1).name());\n        assertEquals(\"2\", gauge.collect().get(0).labels().get(1).value());\n    }\n\n    @Test\n    void twoLabelsIncDecSet() {\n        var registry = new CollectorRegistry(false, false);\n        var gauge = Gauge.name(\"test\").labels(\"a\", \"b\").register(registry);\n        gauge.labels(\"1\", \"2\").inc(17);\n        assertEquals(1, gauge.collect().size());\n        assertEquals(17, gauge.collect().get(0).value());\n        assertEquals(2, gauge.collect().get(0).labels().size());\n        assertEquals(\"a\", gauge.collect().get(0).labels().get(0).name());\n        assertEquals(\"1\", gauge.collect().get(0).labels().get(0).value());\n        assertEquals(\"b\", gauge.collect().get(0).labels().get(1).name());\n        assertEquals(\"2\", gauge.collect().get(0).labels().get(1).value());\n        gauge.labels(\"1\", \"2\").dec(20);\n        assertEquals(-3, gauge.collect().get(0).value());\n        gauge.labels(\"1\", \"2\").set(1337);\n        assertEquals(1337, gauge.collect().get(0).value());\n    }\n\n    @Test\n    void threeLabels() {\n        var registry = new CollectorRegistry(false, false);\n        var gauge = Gauge.name(\"test\").labels(\"a\", \"b\", \"c\").register(registry);\n        gauge.labels(\"1\", \"2\", \"3\").inc();\n        assertEquals(1, gauge.collect().size());\n        assertEquals(1, gauge.collect().get(0).value());\n        assertEquals(3, gauge.collect().get(0).labels().size());\n        assertEquals(\"a\", gauge.collect().get(0).labels().get(0).name());\n        assertEquals(\"1\", gauge.collect().get(0).labels().get(0).value());\n        assertEquals(\"b\", gauge.collect().get(0).labels().get(1).name());\n        assertEquals(\"2\", gauge.collect().get(0).labels().get(1).value());\n        assertEquals(\"c\", gauge.collect().get(0).labels().get(2).name());\n        assertEquals(\"3\", gauge.collect().get(0).labels().get(2).value());\n    }\n\n    @Test\n    void threeLabelsIncDecSet() {\n        var registry = new CollectorRegistry(false, false);\n        var gauge = Gauge.name(\"test\").labels(\"a\", \"b\", \"c\").register(registry);\n        gauge.labels(\"1\", \"2\", \"3\").inc(17);\n        assertEquals(1, gauge.collect().size());\n        assertEquals(17, gauge.collect().get(0).value());\n        assertEquals(3, gauge.collect().get(0).labels().size());\n        assertEquals(\"a\", gauge.collect().get(0).labels().get(0).name());\n        assertEquals(\"1\", gauge.collect().get(0).labels().get(0).value());\n        assertEquals(\"b\", gauge.collect().get(0).labels().get(1).name());\n        assertEquals(\"2\", gauge.collect().get(0).labels().get(1).value());\n        assertEquals(\"c\", gauge.collect().get(0).labels().get(2).name());\n        assertEquals(\"3\", gauge.collect().get(0).labels().get(2).value());\n        gauge.labels(\"1\", \"2\", \"3\").dec(20);\n        assertEquals(-3, gauge.collect().get(0).value());\n        gauge.labels(\"1\", \"2\", \"3\").set(1337);\n        assertEquals(1337, gauge.collect().get(0).value());\n    }\n}\n"
  },
  {
    "path": "metrics/src/test/java/org/openjdk/skara/metrics/PrometheusExpoterTests.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.metrics;\n\nimport org.junit.jupiter.api.*;\nimport java.util.*;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass PrometheusExporterTests {\n    private static Metric metric(Metric.Type type, String name, double value, String... labelsAndValues) {\n        var labels = new ArrayList<Metric.Label>();\n        for (var labelAndValue : labelsAndValues) {\n            var parts = labelAndValue.split(\"=\");\n            labels.add(new Metric.Label(parts[0], parts[1]));\n        }\n        return new Metric(type, name, labels, value);\n    }\n\n    private static Metric counter(String name, double value, String... labelsAndValues) {\n        return metric(Metric.Type.COUNTER, name, value, labelsAndValues);\n    }\n\n    private static Metric gauge(String name, double value, String... labelsAndValues) {\n        return metric(Metric.Type.GAUGE, name, value, labelsAndValues);\n    }\n\n    private static List<String> export(Metric... metrics) {\n        return export(Arrays.asList(metrics));\n    }\n\n    private static List<String> export(List<Metric> metrics) {\n        var output = new PrometheusExporter().export(metrics);\n        return Arrays.asList(output.split(\"\\n\"));\n    }\n\n    @Test\n    void counter() {\n        var lines = export(counter(\"test\", 17.3));\n        assertEquals(2, lines.size());\n        assertEquals(\"# TYPE test counter\", lines.get(0));\n        assertEquals(\"test 17.3\", lines.get(1));\n    }\n\n    @Test\n    void counterWithOneLabel() {\n        var lines = export(counter(\"test\", 17.3, \"a=1\"));\n        assertEquals(2, lines.size());\n        assertEquals(\"# TYPE test counter\", lines.get(0));\n        assertEquals(\"test{a=\\\"1\\\"} 17.3\", lines.get(1));\n    }\n\n    @Test\n    void counterWithTwoLabels() {\n        var lines = export(counter(\"test\", 17.3, \"a=1\", \"b=2\"));\n        assertEquals(2, lines.size());\n        assertEquals(\"# TYPE test counter\", lines.get(0));\n        assertEquals(\"test{a=\\\"1\\\",b=\\\"2\\\"} 17.3\", lines.get(1));\n    }\n\n    @Test\n    void counterWithThreeLabels() {\n        var lines = export(counter(\"test\", 17.3, \"a=1\", \"b=2\", \"c=3\"));\n        assertEquals(2, lines.size());\n        assertEquals(\"# TYPE test counter\", lines.get(0));\n        assertEquals(\"test{a=\\\"1\\\",b=\\\"2\\\",c=\\\"3\\\"} 17.3\", lines.get(1));\n    }\n\n    @Test\n    void sameCounterTwice() {\n        var lines = export(\n            counter(\"test\", 17.3, \"a=1\"),\n            counter(\"test\", 8.6, \"a=2\")\n        );\n        assertEquals(3, lines.size());\n        assertEquals(\"# TYPE test counter\", lines.get(0));\n        assertEquals(\"test{a=\\\"1\\\"} 17.3\", lines.get(1));\n        assertEquals(\"test{a=\\\"2\\\"} 8.6\", lines.get(2));\n    }\n\n    @Test\n    void twoDifferentCounters() {\n        var lines = export(\n            counter(\"test-1\", 17.3, \"a=1\"),\n            counter(\"test-2\", 8.6, \"a=2\")\n        );\n        assertEquals(4, lines.size());\n        assertEquals(\"# TYPE test-1 counter\", lines.get(0));\n        assertEquals(\"test-1{a=\\\"1\\\"} 17.3\", lines.get(1));\n        assertEquals(\"# TYPE test-2 counter\", lines.get(2));\n        assertEquals(\"test-2{a=\\\"2\\\"} 8.6\", lines.get(3));\n    }\n\n    @Test\n    void gauge() {\n        var lines = export(gauge(\"test\", 17.3));\n        assertEquals(2, lines.size());\n        assertEquals(\"# TYPE test gauge\", lines.get(0));\n        assertEquals(\"test 17.3\", lines.get(1));\n    }\n\n    @Test\n    void gaugeWithOneLabel() {\n        var lines = export(gauge(\"test\", 17.3, \"a=1\"));\n        assertEquals(2, lines.size());\n        assertEquals(\"# TYPE test gauge\", lines.get(0));\n        assertEquals(\"test{a=\\\"1\\\"} 17.3\", lines.get(1));\n    }\n\n    @Test\n    void gaugeWithTwoLabels() {\n        var lines = export(gauge(\"test\", 17.3, \"a=1\", \"b=2\"));\n        assertEquals(2, lines.size());\n        assertEquals(\"# TYPE test gauge\", lines.get(0));\n        assertEquals(\"test{a=\\\"1\\\",b=\\\"2\\\"} 17.3\", lines.get(1));\n    }\n\n    @Test\n    void gaugeWithThreeLabels() {\n        var lines = export(gauge(\"test\", 17.3, \"a=1\", \"b=2\", \"c=3\"));\n        assertEquals(2, lines.size());\n        assertEquals(\"# TYPE test gauge\", lines.get(0));\n        assertEquals(\"test{a=\\\"1\\\",b=\\\"2\\\",c=\\\"3\\\"} 17.3\", lines.get(1));\n    }\n\n    @Test\n    void sameGaugeTwice() {\n        var lines = export(\n            gauge(\"test\", 17.3, \"a=1\"),\n            gauge(\"test\", 8.6, \"a=2\")\n        );\n        assertEquals(3, lines.size());\n        assertEquals(\"# TYPE test gauge\", lines.get(0));\n        assertEquals(\"test{a=\\\"1\\\"} 17.3\", lines.get(1));\n        assertEquals(\"test{a=\\\"2\\\"} 8.6\", lines.get(2));\n    }\n\n    @Test\n    void twoDifferentGauges() {\n        var lines = export(\n            gauge(\"test-1\", 17.3, \"a=1\"),\n            gauge(\"test-2\", 8.6, \"a=2\")\n        );\n        assertEquals(4, lines.size());\n        assertEquals(\"# TYPE test-1 gauge\", lines.get(0));\n        assertEquals(\"test-1{a=\\\"1\\\"} 17.3\", lines.get(1));\n        assertEquals(\"# TYPE test-2 gauge\", lines.get(2));\n        assertEquals(\"test-2{a=\\\"2\\\"} 8.6\", lines.get(3));\n    }\n}\n"
  },
  {
    "path": "network/build.gradle",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.network'\n    test {\n        requires 'org.openjdk.skara.test'\n        requires 'org.junit.jupiter.api'\n        requires 'jdk.httpserver'\n        opens 'org.openjdk.skara.network' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':json')\n    implementation project(':metrics')\n    testImplementation project(':test')\n}\n\npublishing {\n    publications {\n        network(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "network/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.network {\n    requires java.logging;\n    requires org.openjdk.skara.json;\n    requires org.openjdk.skara.metrics;\n    requires java.net.http;\n\n    exports org.openjdk.skara.network;\n}\n"
  },
  {
    "path": "network/src/main/java/org/openjdk/skara/network/RestRequest.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.network;\n\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.metrics.Counter;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.net.http.*;\nimport java.nio.charset.StandardCharsets;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.*;\nimport java.util.*;\nimport java.util.logging.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class RestRequest {\n    private RestRequestCache cache = RestRequestCache.INSTANCE;\n    private final static Counter.WithOneLabel requestCounter =\n        Counter.name(\"skara_http_requests\")\n               .labels(\"method\")\n               .register();\n    private final static Counter.WithTwoLabels responseCounter =\n        Counter.name(\"skara_http_responses\")\n               .labels(\"code\", \"error_handled\")\n               .register();\n\n    private enum RequestType {\n        GET,\n        POST,\n        PUT,\n        PATCH,\n        DELETE\n    }\n\n    @FunctionalInterface\n    public interface AuthenticationGenerator {\n        List<String> getAuthHeaders(HttpRequest.Builder request);\n    }\n\n    @FunctionalInterface\n    public interface NextLinkExtractor {\n        Optional<HttpRequest.Builder> getNextLinkRequest(HttpResponse<String> response);\n    }\n\n    @FunctionalInterface\n    public interface ErrorTransform {\n        Optional<JSONValue> onError(HttpResponse<String> response);\n    }\n\n    public class QueryBuilder {\n        private class Param {\n            String key;\n            String value;\n        }\n\n        private final RequestType queryType;\n        private final String endpoint;\n\n        private final List<Param> params = new ArrayList<>();\n        private final List<Param> bodyParams = new ArrayList<>();\n        private final Map<String, String> headers = new HashMap<>();\n        private JSONValue body;\n        private String rawBody;\n        private int maxPages;\n        private ErrorTransform onError;\n        private String sha256Header;\n        private boolean skipLimiter = false;\n        private boolean failOnEmptyResponse = false;\n        private Duration timeout = Duration.ofSeconds(30);\n\n        private QueryBuilder(RequestType queryType, String endpoint) {\n            this.queryType = queryType;\n            this.endpoint = endpoint;\n\n            body = null;\n            rawBody = null;\n            maxPages = Integer.MAX_VALUE;\n            onError = null;\n        }\n\n        private String composedBody() {\n            if (rawBody != null && (body != null || !bodyParams.isEmpty())) {\n                throw new RuntimeException(\"Cannot mix raw body and JSON body in request\");\n            }\n\n            if (rawBody != null) {\n                return rawBody;\n            }\n\n            if (body == null && queryType == RequestType.GET && bodyParams.isEmpty()) {\n                return null;\n            }\n\n            var finalBody = body == null ? JSON.object() : body.asObject();\n            for (var param : bodyParams) {\n                finalBody.put(param.key, param.value);\n            }\n            return finalBody.toString();\n        }\n\n        private boolean isJSON() {\n            return body != null || !bodyParams.isEmpty();\n        }\n\n        /**\n         * Pass a parameter through the url query mechanism.\n         * @param key\n         * @param value\n         * @return\n         */\n        public QueryBuilder param(String key, String value) {\n            var param = new Param();\n            param.key = key;\n            param.value = value;\n            params.add(param);\n            return this;\n        }\n\n        /**\n         * Adds a body parameter that will be encoded in a json key-value structure.\n         * @param key\n         * @param value\n         * @return\n         */\n        public QueryBuilder body(String key, String value) {\n            var param = new Param();\n            param.key = key;\n            param.value = value;\n            bodyParams.add(param);\n            return this;\n        }\n\n        /**\n         * Sets the request body encoded as json.\n         * @param json\n         * @return\n         */\n        public QueryBuilder body(JSONValue json) {\n            body = json;\n            return this;\n        }\n\n        /**\n         * Sets the request body encoded as raw POST data.\n         * @param data\n         * @return\n         */\n        public QueryBuilder body(String data) {\n            rawBody = data;\n            return this;\n        }\n\n        /**\n         * When parsing paginated results, stop after this number of pages\n         * @param count 0 means all\n         * @return\n         */\n        public QueryBuilder maxPages(int count) {\n            maxPages = count;\n            return this;\n        }\n\n        /**\n         * If an http error code is returned, apply the given function to the response to obtain a valid\n         * return value instead of throwing an exception.\n         * @param errorTransform\n         * @return\n         */\n        public QueryBuilder onError(ErrorTransform errorTransform) {\n            onError = errorTransform;\n            return this;\n        }\n\n        public QueryBuilder header(String name, String value) {\n            headers.put(name, value);\n            return this;\n        }\n\n        /**\n         * Optionally name a header where a sha256 hash of the contents will be added.\n         * This is commonly used by cloud vendors as part of verifying requests.\n         */\n        public QueryBuilder sha256Header(String name) {\n            sha256Header = name;\n            return this;\n        }\n\n        public QueryBuilder skipLimiter(boolean skipLimiter) {\n            this.skipLimiter = skipLimiter;\n            return this;\n        }\n\n        public QueryBuilder failOnEmptyResponse(boolean failOnEmptyResponse) {\n            this.failOnEmptyResponse = failOnEmptyResponse;\n            return this;\n        }\n\n        /**\n         * Set the HTTP timeout for the request. Defaults to 30 seconds.\n         */\n        public QueryBuilder timeout(Duration timeout) {\n            this.timeout = timeout;\n            return this;\n        }\n\n        public JSONValue execute() {\n            try {\n                return RestRequest.this.execute(this);\n            } catch (IOException e) {\n                throw new UncheckedIOException(e);\n            }\n        }\n\n        public String executeUnparsed() throws IOException {\n            return RestRequest.this.executeUnparsed(this);\n        }\n\n        public HttpRequest build() {\n            return RestRequest.this.build(this);\n        }\n\n        @Override\n        public String toString() {\n            return \"QueryBuilder: type: \" + queryType +\n                    \", endpoint: \" + endpoint +\n                    \", body: \" + composedBody();\n        }\n    }\n\n    private final URI apiBase;\n    private final String authId;\n    private final AuthenticationGenerator authGen;\n    private final NextLinkExtractor nextLinkExtractor;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.host.network\");\n\n    public RestRequest(URI apiBase, String authId, AuthenticationGenerator authGen,\n                       NextLinkExtractor nextLinkExtractor) {\n        this.apiBase = apiBase;\n        this.authId = authId;\n        this.authGen = authGen;\n        this.nextLinkExtractor = nextLinkExtractor;\n    }\n\n    public RestRequest(URI apiBase, String authId, AuthenticationGenerator authGen) {\n        this.apiBase = apiBase;\n        this.authId = authId;\n        this.authGen = authGen;\n        this.nextLinkExtractor = this::getNextLinkRequest;\n    }\n\n    public RestRequest(URI apiBase) {\n        this(apiBase, null, null);\n    }\n\n    /**\n     * Creates a new request restricted to the given endpoint.\n     * @param endpoint\n     * @return\n     */\n    public RestRequest restrict(String endpoint) {\n        return new RestRequest(URIBuilder.base(apiBase).appendPath(endpoint).build(), authId, authGen);\n    }\n\n    private URIBuilder getEndpointURI(String endpoint) {\n        return URIBuilder.base(apiBase)\n                         .appendPath(endpoint);\n    }\n\n    private HttpRequest.Builder getHttpRequestBuilder(URI uri) {\n        var builder = HttpRequest.newBuilder()\n                                 .uri(uri)\n                                 .timeout(Duration.ofSeconds(30));\n        return builder;\n    }\n\n    private void logRateLimit(HttpHeaders headers) {\n        if ((headers.firstValue(\"x-ratelimit-limit\").isEmpty()) ||\n                (headers.firstValue(\"x-ratelimit-remaining\").isEmpty()) ||\n                (headers.firstValue(\"x-ratelimit-reset\").isEmpty())) {\n            return;\n        }\n\n        var limit = Integer.parseInt(headers.firstValue(\"x-ratelimit-limit\").get());\n        var remaining = Integer.parseInt(headers.firstValue(\"x-ratelimit-remaining\").get());\n        var reset = Integer.parseInt(headers.firstValue(\"x-ratelimit-reset\").get());\n        var timeToReset = Duration.between(Instant.now(), Instant.ofEpochSecond(reset));\n\n        var level = Level.FINE;\n        var remainingPercentage = (remaining * 100) / limit;\n        if (remainingPercentage < 10) {\n            level = Level.SEVERE;\n        } else if (remainingPercentage < 20) {\n            level = Level.WARNING;\n        } else if (remainingPercentage < 50) {\n            level = Level.INFO;\n        }\n        log.log(level,\"Rate limit: \" + limit + \" Remaining: \" + remaining + \" (\" + remainingPercentage + \"%) \" +\n                \"Resets in: \" + timeToReset.toMinutes() + \" minutes\");\n    }\n\n    private Duration retryBackoffStep = Duration.ofSeconds(1);\n\n    void setRetryBackoffStep(Duration duration) {\n        retryBackoffStep = duration;\n    }\n\n    private HttpResponse<String> sendRequest(HttpRequest.Builder request, boolean skipLimiter) throws IOException {\n        HttpResponse<String> response;\n\n        var authHeaders = addAuthHeaders(request);\n\n        var retryCount = 0;\n        while (true) {\n            try {\n                response = cache.send(authId, request, skipLimiter);\n                // If the status code is 301(Moved Permanently), follow the redirect link\n                if (response.statusCode() == 301 && retryCount < 2) {\n                    var location = response.headers().firstValue(\"location\");\n                    if (location.isPresent()) {\n                        request = request.uri(URI.create(location.get()));\n                    }\n                }\n                // If we are using authorization and get a 401, we need to retry to give\n                // the authorization mechanism a chance to refresh stale tokens. Retry if\n                // we get a new set of authorization headers.\n                else if (response.statusCode() == 401 && retryCount < 2 && authHeaders != null\n                        && !authHeaders.equals(addAuthHeaders(request))) {\n                    log.info(\"Failed authorization for request: \" + request.build().uri()\n                            + \", retry count: \" + retryCount);\n                } else {\n                    break;\n                }\n            } catch (InterruptedException | IOException e) {\n                if (retryCount < 5) {\n                    try {\n                        Thread.sleep(retryBackoffStep.multipliedBy(retryCount));\n                    } catch (InterruptedException ignored) {\n                    }\n                } else {\n                    try {\n                        throw e;\n                    } catch (InterruptedException ex) {\n                        throw new RuntimeException(ex);\n                    }\n                }\n            }\n            retryCount++;\n        }\n\n        logRateLimit(response.headers());\n        return response;\n    }\n\n    /**\n     * Adds authorization headers to the request builder. Returns the headers that\n     * were added or null if no authorization mechanism was defined.\n     */\n    private List<String> addAuthHeaders(HttpRequest.Builder request) {\n        if (authGen != null) {\n            var authHeaders = authGen.getAuthHeaders(request);\n            for (int i = 0; i < authHeaders.size() - 1; i += 2) {\n                String name  = authHeaders.get(i);\n                String value = authHeaders.get(i + 1);\n                request.setHeader(name, value);\n            }\n            return authHeaders;\n        }\n        return null;\n    }\n\n    private JSONValue parseResponse(HttpResponse<String> response) {\n        if (response.body().isEmpty()) {\n            return JSON.of();\n        }\n        try {\n            return JSON.parse(response.body());\n        } catch (RuntimeException e) {\n            throw new UncheckedRestException(\"Failed to parse response\", e, response.statusCode(), response.request());\n        }\n    }\n\n    private Optional<JSONValue> transformBadResponse(HttpResponse<String> response, QueryBuilder queryBuilder) {\n        if (response.statusCode() >= 400) {\n            if (queryBuilder.onError != null) {\n                var transformed = queryBuilder.onError.onError(response);\n                if (transformed.isPresent()) {\n                    return transformed;\n                }\n            }\n            log.warning(\"Request \" + response.request().method() + \" \" + response.request().uri()\n                    + \" returned bad status: \" + response.statusCode());\n            log.info(queryBuilder.toString());\n            log.info(response.body());\n            throw new UncheckedRestException(response.statusCode(), response.request());\n        } else {\n            return Optional.empty();\n        }\n    }\n\n    private HttpRequest.Builder createRequest(RequestType requestType, String endpoint, String body,\n                                              List<QueryBuilder.Param> params, Map<String, String> headers,\n                                              boolean isJSON, String sha256Header, Duration timeout) {\n        var uriBuilder = URIBuilder.base(apiBase);\n        if (endpoint != null && !endpoint.isEmpty()) {\n            uriBuilder = uriBuilder.appendPath(endpoint);\n        }\n        if (!params.isEmpty()) {\n            var query = new LinkedHashMap<String, List<String>>();\n            for (var param : params) {\n                if (!query.containsKey(param.key)) {\n                    query.put(param.key, new ArrayList<String>());\n                }\n                query.get(param.key).add(param.value);\n            }\n            uriBuilder.setQuery(query);\n        }\n        var uri = uriBuilder.build();\n\n        var requestBuilder = HttpRequest.newBuilder()\n                                        .uri(uri)\n                                        .timeout(timeout);\n\n        if (isJSON) {\n            requestBuilder = requestBuilder.header(\"Content-type\", \"application/json\");\n        }\n\n        if (body != null) {\n            requestBuilder.method(requestType.name(), HttpRequest.BodyPublishers.ofString(body));\n            if (sha256Header != null) {\n                try {\n                    var digest = MessageDigest.getInstance(\"SHA-256\");\n                    var hash = digest.digest(body.getBytes(StandardCharsets.UTF_8));\n                    var encoded  = new String(Base64.getEncoder().encode(hash), StandardCharsets.UTF_8);\n                    requestBuilder.header(sha256Header, encoded);\n                } catch (NoSuchAlgorithmException e) {\n                    throw new Error(\"SHA-256 algorithm not found\");\n                }\n            }\n        }\n        headers.forEach(requestBuilder::header);\n        return requestBuilder;\n    }\n\n    private final Pattern linkPattern = Pattern.compile(\"<(.*?)>; rel=\\\"(.*?)\\\"\");\n\n    private Map<String, String> parseLink(String link) {\n        return linkPattern.matcher(link).results()\n                          .collect(Collectors.toMap(m -> m.group(2), m -> m.group(1)));\n    }\n\n    private JSONValue combinePages(List<JSONValue> pages) {\n        if (pages.get(0).isArray()) {\n            return new JSONArray(pages.stream()\n                                      .map(JSONValue::asArray)\n                                      .flatMap(JSONArray::stream)\n                                      .toArray(JSONValue[]::new));\n        } else {\n            // Find the largest array - that should be the paginated one\n            JSONValue paginated = null;\n            for (var field : pages.get(0).fields()) {\n                if (field.value().isArray()) {\n                    if ((paginated == null) || field.value().asArray().size() > paginated.asArray().size()) {\n                        paginated = field.value();\n                    }\n                }\n            }\n\n            var ret = JSON.object();\n            for (var field : pages.get(0).fields()) {\n                if (field.value() == paginated) {\n                    var combined = new JSONArray(pages.stream()\n                                                      .map(page -> page.get(field.name()).asArray())\n                                                      .flatMap(JSONArray::stream)\n                                                      .toArray(JSONValue[]::new));\n                    ret.put(field.name(), combined);\n                } else {\n                    ret.put(field.name(), field.value());\n                }\n            }\n            return ret;\n        }\n    }\n\n    private Optional<HttpRequest.Builder> getNextLinkRequest(HttpResponse<String> response) {\n        var link = response.headers().firstValue(\"Link\");\n        if (link.isEmpty()) {\n            return Optional.empty();\n        }\n        var links = parseLink(link.get());\n        if (!links.containsKey(\"next\")) {\n            return Optional.empty();\n        }\n        var uri = URI.create(links.get(\"next\"));\n        return Optional.of(getHttpRequestBuilder(uri).GET());\n    }\n\n    private HttpRequest build(QueryBuilder queryBuilder) {\n        var request = createRequest(queryBuilder.queryType, queryBuilder.endpoint, queryBuilder.composedBody(),\n                queryBuilder.params, queryBuilder.headers, queryBuilder.isJSON(), queryBuilder.sha256Header,\n                queryBuilder.timeout);\n        return request.build();\n    }\n\n    private JSONValue execute(QueryBuilder queryBuilder) throws IOException {\n        var request = createRequest(queryBuilder.queryType, queryBuilder.endpoint, queryBuilder.composedBody(),\n                queryBuilder.params, queryBuilder.headers, queryBuilder.isJSON(), queryBuilder.sha256Header,\n                queryBuilder.timeout);\n        requestCounter.labels(queryBuilder.queryType.toString()).inc();\n        var response = sendRequest(request, queryBuilder.skipLimiter);\n        var errorTransform = transformBadResponse(response, queryBuilder);\n        responseCounter.labels(Integer.toString(response.statusCode()), Boolean.toString(errorTransform.isPresent())).inc();\n        if (errorTransform.isPresent()) {\n            return errorTransform.get();\n        }\n\n        if (queryBuilder.failOnEmptyResponse && response.body().isBlank()) {\n            throw new UncheckedRestException(\"Empty response body\"\n                    + response.headers().firstValue(\"Location\").map(s -> \", redirect: \" + s).orElse(\"\"),\n                    response.statusCode(), request.build());\n        }\n\n        var nextRequest = nextLinkExtractor.getNextLinkRequest(response);\n        if (nextRequest.isEmpty() || queryBuilder.maxPages < 2) {\n            return parseResponse(response);\n        }\n\n        // If a pagination header is present, we have to collect all responses\n        var ret = new LinkedList<JSONValue>();\n        var parsedResponse = parseResponse(response);\n        ret.add(parsedResponse);\n\n        while (nextRequest.isPresent() && ret.size() < queryBuilder.maxPages) {\n            requestCounter.labels(queryBuilder.queryType.toString()).inc();\n            response = sendRequest(nextRequest.get(), queryBuilder.skipLimiter);\n\n            // If an error occurs during paginated parsing, we have to discard all previous data\n            errorTransform = transformBadResponse(response, queryBuilder);\n            responseCounter.labels(Integer.toString(response.statusCode()), Boolean.toString(errorTransform.isPresent())).inc();\n            if (errorTransform.isPresent()) {\n                return errorTransform.get();\n            }\n\n            nextRequest = nextLinkExtractor.getNextLinkRequest(response);\n\n            parsedResponse = parseResponse(response);\n            ret.add(parsedResponse);\n        }\n        return combinePages(ret);\n    }\n\n    private String executeUnparsed(QueryBuilder queryBuilder) throws IOException {\n        var request = createRequest(queryBuilder.queryType, queryBuilder.endpoint, queryBuilder.composedBody(),\n                queryBuilder.params, queryBuilder.headers, queryBuilder.isJSON(), queryBuilder.sha256Header,\n                queryBuilder.timeout);\n        requestCounter.labels(queryBuilder.queryType.toString()).inc();\n        var response = sendRequest(request, queryBuilder.skipLimiter);\n        responseCounter.labels(Integer.toString(response.statusCode()), Boolean.toString(false)).inc();\n        if (response.statusCode() >= 400) {\n            String errorMessage = null;\n            RuntimeException parseException = null;\n            try {\n                var responseJSONValue = JSON.parse(response.body());\n                if (responseJSONValue.contains(\"message\")) {\n                    errorMessage = responseJSONValue.get(\"message\").asString();\n                }\n            } catch (RuntimeException e) {\n                parseException = e;\n            }\n\n            if (errorMessage != null) {\n                throw new UncheckedRestException(errorMessage, response.statusCode(), response.request());\n            }\n\n            var exception = new UncheckedRestException(response.statusCode(), response.request());\n            if (parseException != null) {\n                exception.addSuppressed(parseException);\n            }\n            throw exception;\n        }\n        return response.body();\n    }\n\n    public QueryBuilder get(String endpoint) {\n        return new QueryBuilder(RequestType.GET, endpoint).failOnEmptyResponse(true);\n    }\n\n    public QueryBuilder get() {\n        return get(null).failOnEmptyResponse(true);\n    }\n\n    public QueryBuilder post(String endpoint) {\n        return new QueryBuilder(RequestType.POST, endpoint);\n    }\n\n    public QueryBuilder post() {\n        return post(null);\n    }\n\n    public QueryBuilder put(String endpoint) {\n        return new QueryBuilder(RequestType.PUT, endpoint);\n    }\n\n    public QueryBuilder put() {\n        return put(null);\n    }\n\n    public QueryBuilder patch(String endpoint) {\n        return new QueryBuilder(RequestType.PATCH, endpoint);\n    }\n\n    public QueryBuilder patch() {\n        return patch(null);\n    }\n\n    public QueryBuilder delete(String endpoint) {\n        return new QueryBuilder(RequestType.DELETE, endpoint);\n    }\n\n    public QueryBuilder delete() {\n        return delete(null);\n    }\n\n    public static void evictOldCacheData() {\n        RestRequestCache.INSTANCE.evictOldData();\n    }\n}\n"
  },
  {
    "path": "network/src/main/java/org/openjdk/skara/network/RestRequestCache.java",
    "content": "/*\n * Copyright (c) 2020, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.network;\n\nimport java.util.logging.Level;\nimport org.openjdk.skara.metrics.Counter;\nimport org.openjdk.skara.metrics.Gauge;\n\nimport javax.net.ssl.SSLSession;\nimport java.io.IOException;\nimport java.net.URI;\nimport java.net.http.*;\nimport java.time.*;\nimport java.util.*;\nimport java.util.concurrent.*;\nimport java.util.concurrent.locks.*;\nimport java.util.logging.Logger;\n\nenum RestRequestCache {\n    INSTANCE;\n\n    private final static Gauge cachedEntriesGauge = Gauge.name(\"skara_response_cache_size\").register();\n    private final static Counter cacheHitsCounter = Counter.name(\"skara_response_cache_hits\").register();\n\n    private static class RequestContext {\n        private final String authId;\n        private final HttpRequest unauthenticatedRequest;\n        private final Instant created;\n\n        private RequestContext(String authId, HttpRequest unauthenticatedRequest) {\n            this.authId = authId;\n            this.unauthenticatedRequest = unauthenticatedRequest;\n            created = Instant.now();\n        }\n\n        @Override\n        public boolean equals(Object o) {\n            if (this == o) {\n                return true;\n            }\n            if (o == null || getClass() != o.getClass()) {\n                return false;\n            }\n            RequestContext that = (RequestContext) o;\n            return Objects.equals(authId, that.authId) && unauthenticatedRequest.equals(that.unauthenticatedRequest);\n        }\n\n        @Override\n        public int hashCode() {\n            return Objects.hash(authId, unauthenticatedRequest);\n        }\n\n        public Duration age() {\n            return Duration.between(created, Instant.now());\n        }\n    }\n\n    private class CacheEntry {\n        private final HttpResponse<String> response;\n        private final Instant callTime;\n\n        public CacheEntry(HttpResponse<String> response, Instant callTime) {\n            this.response = response;\n            this.callTime = callTime;\n        }\n    }\n\n    private final Map<RequestContext, CacheEntry> cachedResponses = new ConcurrentHashMap<>();\n    private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.network\");\n    private final ConcurrentHashMap<String, Lock> authLocks = new ConcurrentHashMap<>();\n    private final ConcurrentHashMap<String, Lock> authNonGetLocks = new ConcurrentHashMap<>();\n    private final ConcurrentHashMap<String, Instant> lastUpdates = new ConcurrentHashMap<>();\n\n    private static class LockWithTimeout implements AutoCloseable {\n        private final Lock lock;\n\n        LockWithTimeout(Lock lock) {\n            this.lock = lock;\n            while (true) {\n                try {\n                    var locked = lock.tryLock(10, TimeUnit.MINUTES);\n                    if (!locked) {\n                        throw new RuntimeException(\"Unable to grab lock in 10 minutes\");\n                    }\n                    return;\n                } catch (InterruptedException ignored) {\n                }\n            }\n        }\n\n        @Override\n        public void close() {\n            lock.unlock();\n        }\n    }\n\n    private static class CachedHttpResponse<T> implements HttpResponse<T> {\n        private final HttpResponse<T> original;\n        private final HttpResponse<T> fromRequest;\n\n        CachedHttpResponse(HttpResponse<T> original, HttpResponse<T> fromRequest) {\n            this.original = original;\n            this.fromRequest = fromRequest;\n        }\n\n        @Override\n        public int statusCode() {\n            return original.statusCode();\n        }\n\n        @Override\n        public HttpRequest request() {\n            return fromRequest.request();\n        }\n\n        @Override\n        public Optional<HttpResponse<T>> previousResponse() {\n            return fromRequest.previousResponse();\n        }\n\n        @Override\n        public HttpHeaders headers() {\n            var combined = new HashMap<String, List<String>>();\n            for (var header : original.headers().map().entrySet()) {\n                combined.put(header.getKey(), header.getValue());\n            }\n            for (var header : fromRequest.headers().map().entrySet()) {\n                combined.put(header.getKey(), header.getValue());\n            }\n            return HttpHeaders.of(combined, (a, b) -> true);\n        }\n\n        @Override\n        public T body() {\n            return original.body();\n        }\n\n        @Override\n        public Optional<SSLSession> sslSession() {\n            return fromRequest.sslSession();\n        }\n\n        @Override\n        public URI uri() {\n            return fromRequest.uri();\n        }\n\n        @Override\n        public HttpClient.Version version() {\n            return fromRequest.version();\n        }\n    }\n\n    private Duration maxAllowedAge(RequestContext requestContext) {\n        // Known stable caches can afford a longer timeout - others expire faster\n        if (requestContext.unauthenticatedRequest.uri().toString().contains(\"github.com\")) {\n            return Duration.ofMinutes(30);\n        } else {\n            return Duration.ofMinutes(5);\n        }\n    }\n\n    HttpResponse<String> send(String authId, HttpRequest.Builder requestBuilder, boolean skipLimiter) throws IOException, InterruptedException {\n        if (authId == null) {\n            authId = \"anonymous\";\n        }\n        var unauthenticatedRequest = requestBuilder.build();\n        var requestContext = new RequestContext(authId, unauthenticatedRequest);\n        var authLock = authLocks.computeIfAbsent(authId, id -> new ReentrantLock());\n        if (unauthenticatedRequest.method().equals(\"GET\") || skipLimiter) {\n            var cached = cachedResponses.get(requestContext);\n            if (cached != null) {\n                if (Instant.now().minus(maxAllowedAge(requestContext)).isBefore(cached.callTime)) {\n                    var tag = cached.response.headers().firstValue(\"ETag\");\n                    tag.ifPresent(value -> requestBuilder.header(\"If-None-Match\", value));\n                } else {\n                    log.finer(\"Expired response cache for \" + requestContext.unauthenticatedRequest.uri() + \" (\" + requestContext.authId + \")\");\n                }\n            }\n            var finalRequest = requestBuilder.build();\n            HttpResponse<String> response;\n            var beforeLock = Instant.now();\n            try (var ignored = new LockWithTimeout(authLock)) {\n                // Perform requests using a certain account serially\n                var beforeCall = Instant.now();\n                var lockDelay = Duration.between(beforeLock, beforeCall);\n                log.log(Level.FINE, \"Taking lock for \" + finalRequest.method() + \" \" + finalRequest.uri() + \" took \" + lockDelay, lockDelay);\n                response = client.send(finalRequest, HttpResponse.BodyHandlers.ofString());\n                var callDuration = Duration.between(beforeCall, Instant.now());\n                log.log(Level.FINE, \"Calling \" + finalRequest.method() + \" \" + finalRequest.uri().toString() + \" took \" + callDuration, callDuration);\n            }\n            if (cached != null && response.statusCode() == 304) {\n                cacheHitsCounter.inc();\n                log.finer(\"Using cached response for \" + finalRequest + \" (\" + authId + \")\");\n                return new CachedHttpResponse<>(cached.response, response);\n            } else {\n                cachedResponses.put(requestContext, new CacheEntry(response, Instant.now()));\n                cachedEntriesGauge.set(cachedResponses.size());\n                log.finer(\"Updating response cache for \" + finalRequest + \" (\" + authId + \")\");\n                return response;\n            }\n        } else {\n            var authNonGetLock = authNonGetLocks.computeIfAbsent(authId, id -> new ReentrantLock());\n            var finalRequest = requestBuilder.build();\n            log.finer(\"Not using response cache for \" + finalRequest + \" (\" + authId + \")\");\n            Instant lastUpdate;\n            var beforeLock = Instant.now();\n            try (var ignored = new LockWithTimeout(authNonGetLock)) {\n                // Perform at most one update per second\n                lastUpdate = lastUpdates.getOrDefault(authId, Instant.now().minus(Duration.ofDays(1)));\n                var requiredDelay = Duration.between(Instant.now().minus(Duration.ofSeconds(1)), lastUpdate);\n                if (!requiredDelay.isNegative()) {\n                    try {\n                        Thread.sleep(requiredDelay);\n                    } catch (InterruptedException e) {\n                        Thread.currentThread().interrupt();\n                    }\n                }\n                try (var ignored2 = new LockWithTimeout(authLock)) {\n                    var beforeCall = Instant.now();\n                    lastUpdates.put(authId, beforeCall);\n                    var lockDelay = Duration.between(beforeLock, beforeCall);\n                    log.log(Level.FINE, \"Taking lock and adding required delay for \" + finalRequest.method() + \" \" + finalRequest.uri() + \" took \" + lockDelay, lockDelay);\n                    var response = client.send(finalRequest, HttpResponse.BodyHandlers.ofString());\n                    var callDuration = Duration.between(beforeCall, Instant.now());\n                    log.log(Level.FINE, \"Calling \" + finalRequest.method() + \" \" + finalRequest.uri().toString() + \" took \" + callDuration, callDuration);\n                    return response;\n                }\n            } finally {\n                // Invalidate any related GET caches\n                var postUriString = unauthenticatedRequest.uri().toString();\n                var iterator = cachedResponses.entrySet().iterator();\n                while (iterator.hasNext()) {\n                    var entry = iterator.next();\n                    if (entry.getKey().unauthenticatedRequest.uri().toString().startsWith(postUriString)) {\n                        iterator.remove();\n                        cachedEntriesGauge.set(cachedResponses.size());\n                    }\n                }\n            }\n        }\n    }\n\n    /**\n     * This method should be run from time to time to keep the cache from growing indefinitely.\n     */\n    public void evictOldData() {\n        var now = Instant.now();\n        var iterator = cachedResponses.entrySet().iterator();\n        while (iterator.hasNext()) {\n            var entry = iterator.next();\n            if (entry.getValue().callTime.isBefore(now.minus(maxAllowedAge(entry.getKey())))) {\n                iterator.remove();\n                cachedEntriesGauge.set(cachedResponses.size());\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "network/src/main/java/org/openjdk/skara/network/URIBuilder.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.network;\n\nimport java.nio.charset.StandardCharsets;\nimport java.net.*;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\n/**\n * Thrown when invalid URIs are detected in the builder\n */\nclass URIBuilderException extends RuntimeException {\n    URIBuilderException() {\n\n    }\n\n    URIBuilderException(Throwable cause) {\n        addSuppressed(cause);\n    }\n}\n\npublic class URIBuilder {\n\n    private static class URIParts {\n        String scheme;\n        String host;\n        String path;\n        String userInfo;\n        int port;\n        String query;\n        String fragment;\n\n        URIParts(URI uri) {\n            var uriString = uri.toString();\n            scheme = uri.getScheme();\n            host = uri.getHost();\n            var pathStart = host != null ? uriString.indexOf(host) + host.length() : scheme.length() + 3;\n            if (uri.getPort() != -1) {\n                pathStart += Integer.toString(uri.getPort()).length() + 1;\n            }\n            var pathEnd = uriString.indexOf(\"?\", pathStart);\n            if (pathEnd == -1) {\n                pathEnd = uriString.indexOf(\"#\", pathStart);\n            }\n            if (pathEnd != -1) {\n                path = uriString.substring(pathStart, pathEnd);\n            } else {\n                path = uriString.substring(pathStart);\n            }\n            userInfo = uri.getUserInfo();\n            port = uri.getPort();\n            query = uri.getQuery();\n            fragment = uri.getFragment();\n        }\n\n        URI assemble() throws URISyntaxException {\n            // Cannot use the standard URI constructor, as parts of the path may\n            // contain escaped slashes (which would then become doubly escaped).\n            return new URI((scheme == null ? \"http\" : scheme) +\n                    \"://\" +\n                    (userInfo == null ? \"\" : userInfo + \"@\") +\n                    host +\n                    (port == -1 ? \"\" : \":\" + port) +\n                    path +\n                    (query == null ? \"\" : \"?\" + query) +\n                    (fragment == null ? \"\" : \"#\" + fragment));\n        }\n    }\n\n    private URIParts current;\n\n    private URIBuilder(URIParts base) {\n        current = base;\n    }\n\n    public static URIBuilder base(String base) {\n        try {\n            var baseUri = new URI(base);\n            return new URIBuilder(new URIParts(baseUri));\n        } catch (URISyntaxException e) {\n            throw new URIBuilderException(e);\n        }\n    }\n\n    public static URIBuilder base(URI baseUri) {\n        return new URIBuilder(new URIParts(baseUri));\n    }\n\n    /**\n     * Resets the current path to the given one.\n     * @param path\n     * @return\n     */\n    public URIBuilder setPath(String path) {\n        current.path = path;\n        return this;\n    }\n\n    /**\n     * Adds the given path to the current one.\n     * @param path\n     * @return\n     */\n    public URIBuilder appendPath(String path) {\n        current.path = current.path + path;\n        return this;\n    }\n\n    public URIBuilder appendSubDomain(String domain) {\n        current.host = domain + \".\" + current.host;\n        return this;\n    }\n\n    public URIBuilder setAuthentication(String authentication) {\n        current.userInfo = authentication;\n        return this;\n    }\n\n    public URIBuilder setQuery(String query) {\n        current.query = query;\n        return this;\n    }\n\n    public URIBuilder setQuery(Map<String, List<String>> parameters) {\n        var query = parameters.entrySet().stream()\n                .flatMap(p -> {\n                    var key = URLEncoder.encode(p.getKey(), StandardCharsets.UTF_8);\n                    return p.getValue()\n                            .stream()\n                            .map(v -> key + \"=\" + URLEncoder.encode(v, StandardCharsets.UTF_8));\n                })\n                .collect(Collectors.joining(\"&\"));\n\n        current.query = query;\n        return this;\n    }\n\n    /**\n     * Returns the final constructed URI.\n     * @return\n     */\n    public URI build() {\n        try {\n            return current.assemble();\n        } catch (URISyntaxException e) {\n            throw new URIBuilderException(e);\n        }\n    }\n}\n"
  },
  {
    "path": "network/src/main/java/org/openjdk/skara/network/UncheckedRestException.java",
    "content": "/*\n * Copyright (c) 2021, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.network;\n\nimport java.net.http.HttpRequest;\n\n/**\n * Specialized RuntimeException thrown when a REST call receives a response code\n * >=400 that isn't handled. When catching this, details about the failed call\n * has already been logged.\n */\npublic class UncheckedRestException extends RuntimeException {\n    private final int statusCode;\n    private final HttpRequest request;\n\n    public UncheckedRestException(int statusCode, HttpRequest request) {\n        this(\"Request returned bad status\", null, statusCode, request);\n    }\n\n    public UncheckedRestException(String message, int statusCode, HttpRequest request) {\n        this(message, null, statusCode, request);\n    }\n\n    public UncheckedRestException(String message, Throwable cause, int statusCode, HttpRequest request) {\n        super(\"[\" + statusCode + \"][\" + request.method() + \"][\" + request.uri() + \"] \" + message, cause);\n        this.statusCode = statusCode;\n        this.request = request;\n    }\n\n    public int getStatusCode() {\n        return statusCode;\n    }\n\n    public HttpRequest getRequest() {\n        return request;\n    }\n}\n"
  },
  {
    "path": "network/src/test/java/org/openjdk/skara/network/RestRequestTests.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.network;\n\nimport com.sun.net.httpserver.*;\nimport org.openjdk.skara.json.*;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.io.*;\nimport java.net.*;\nimport java.nio.charset.StandardCharsets;\nimport java.security.*;\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.stream.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass RestReceiver implements AutoCloseable {\n    private final HttpServer server;\n    private final List<JSONObject> requests = new ArrayList<>();\n    private final List<String> rawRequests = new ArrayList<>();\n    private List<String> responses;\n    private int responseCode;\n\n    private int truncatedResponseCount = 0;\n    private boolean usedCache = false;\n\n    class Handler implements HttpHandler {\n        private String checksum(String body) {\n            try {\n                var digest = MessageDigest.getInstance(\"SHA-256\");\n                digest.update(body.getBytes(StandardCharsets.UTF_8));\n                return Base64.getUrlEncoder().encodeToString(digest.digest());\n            } catch (NoSuchAlgorithmException e) {\n                throw new RuntimeException(\"Cannot find SHA-256\");\n            }\n        }\n\n        @Override\n        public void handle(HttpExchange exchange) throws IOException {\n            var input = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);\n            if (input.isBlank()) {\n                requests.add(JSON.object());\n            } else {\n                try {\n                    requests.add(JSON.parse(input).asObject());\n                } catch (IllegalStateException e) {\n                    rawRequests.add(input);\n                }\n            }\n\n            var pageQuery = exchange.getRequestURI().getQuery();\n            var requestedPage = pageQuery == null ? 1 : Integer.parseInt(pageQuery);\n            var response = responses.get(requestedPage - 1);\n\n            if (responses.size() > 1) {\n                var responseHeaders = exchange.getResponseHeaders();\n                if (requestedPage < responses.size()) {\n                    responseHeaders.add(\"Link\", \"<\" + getEndpoint() + \"?\" + (requestedPage + 1) + \">; rel=\\\"next\\\"\");\n                }\n                if (requestedPage > 1) {\n                    responseHeaders.add(\"Link\", \"<\" + getEndpoint() + \"?\" + (requestedPage - 1) + \">; rel=\\\"prev\\\"\");\n                }\n            }\n\n            usedCache = false;\n            if (truncatedResponseCount == 0) {\n                var responseHeaders = exchange.getResponseHeaders();\n                var etag = checksum(response);\n                if (exchange.getRequestHeaders().containsKey(\"If-None-Match\")) {\n                    var requestedEtag = exchange.getRequestHeaders().getFirst(\"If-None-Match\");\n                    if (requestedEtag.equals(etag)) {\n                        usedCache = true;\n                        exchange.sendResponseHeaders(304, -1);\n                        return;\n                    }\n                }\n                responseHeaders.add(\"ETag\", etag);\n            }\n\n            exchange.sendResponseHeaders(responseCode, response.length());\n            OutputStream outputStream = exchange.getResponseBody();\n            if (truncatedResponseCount > 0) {\n                truncatedResponseCount--;\n            } else {\n                outputStream.write(response.getBytes());\n            }\n            outputStream.close();\n        }\n    }\n\n    private HttpServer createServer() throws IOException {\n        InetSocketAddress address = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0);\n        var server = HttpServer.create(address, 0);\n        server.createContext(\"/test\", new Handler());\n        server.setExecutor(null);\n        server.start();\n        return server;\n    }\n\n    RestReceiver() throws IOException {\n        this(\"{}\", 200);\n    }\n\n    RestReceiver(String response, int responseCode) throws IOException\n    {\n        this.responses = List.of(response);\n        this.responseCode = responseCode;\n        this.server = createServer();\n    }\n\n    RestReceiver(List<String> responsePages) throws IOException {\n        this.responses = Collections.unmodifiableList(responsePages);\n        this.responseCode = 200;\n        this.server = createServer();\n    }\n\n    URI getEndpoint() {\n        return URIBuilder.base(\"http://\" + server.getAddress().getHostString() + \":\" +  server.getAddress().getPort() + \"/test\").build();\n    }\n\n    List<JSONObject> getRequests() {\n        return requests;\n    }\n\n    List<String> getRawRequests() {\n        return rawRequests;\n    }\n\n    void setTruncatedResponseCount(int count) {\n        truncatedResponseCount = count;\n    }\n\n    boolean usedCached() {\n        return usedCache;\n    }\n\n    void addPage(String responsePage) {\n        this.responses = Stream.concat(responses.stream(), List.of(responsePage).stream())\n                               .collect(Collectors.toList());\n    }\n\n    @Override\n    public void close() {\n        server.stop(0);\n    }\n}\n\nclass RestRequestTests {\n    @Test\n    void simpleRequest() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            var request = new RestRequest(receiver.getEndpoint());\n            request.post(\"/test\").execute();\n        }\n    }\n\n    @Test\n    void pagination() throws IOException {\n        var page1 = \"[ { \\\"a\\\": 1 } ]\";\n        var page2 = \"[ { \\\"b\\\": 2 } ]\";\n        try (var receiver = new RestReceiver(List.of(page1, page2))) {\n            var request = new RestRequest(receiver.getEndpoint());\n            var result = request.post(\"/test\").execute();\n            assertEquals(2, result.asArray().size());\n            assertEquals(1, result.asArray().get(0).get(\"a\").asInt());\n        }\n    }\n\n    @Test\n    void fieldPagination() throws IOException {\n        var page1 = \"{ \\\"a\\\": 1, \\\"b\\\": [ 1, 2, 3 ] }\";\n        var page2 = \"{ \\\"a\\\": 1, \\\"b\\\": [ 4, 5, 6 ] }\";\n        try (var receiver = new RestReceiver(List.of(page1, page2))) {\n            var request = new RestRequest(receiver.getEndpoint());\n            var result = request.post(\"/test\").execute();\n            assertEquals(1, result.get(\"a\").asInt());\n            assertEquals(6, result.get(\"b\").asArray().size());\n            assertEquals(1, result.get(\"b\").asArray().get(0).asInt());\n            assertEquals(4, result.get(\"b\").asArray().get(3).asInt());\n            assertEquals(6, result.get(\"b\").asArray().get(5).asInt());\n        }\n    }\n\n    @Test\n    void retryOnTransientErrors() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            receiver.setTruncatedResponseCount(1);\n\n            var request = new RestRequest(receiver.getEndpoint());\n            request.setRetryBackoffStep(Duration.ofMillis(1));\n            request.post(\"/test\").execute();\n        }\n    }\n\n    @Test\n    void failOnNonTransientErrors() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            receiver.setTruncatedResponseCount(6);\n\n            var request = new RestRequest(receiver.getEndpoint());\n            request.setRetryBackoffStep(Duration.ofMillis(1));\n            assertThrows(RuntimeException.class, () -> request.post(\"/test\").execute());\n        }\n    }\n\n    @Test\n    void transformError() throws IOException {\n        try (var receiver = new RestReceiver(\"{}\", 400)) {\n            var request = new RestRequest(receiver.getEndpoint());\n            var response = request.post(\"/test\")\n                   .onError(r -> Optional.of(JSON.object().put(\"transformed\", true)))\n                   .execute();\n            assertTrue(response.contains(\"transformed\"));\n        }\n    }\n\n    @Test\n    void parseError() throws IOException {\n        try (var receiver = new RestReceiver(\"{{bad_json\", 200)) {\n            var request = new RestRequest(receiver.getEndpoint());\n            assertThrows(RuntimeException.class, () -> request.post(\"/test\").execute());\n        }\n    }\n\n    @Test\n    void unparsed() throws IOException {\n        try (var receiver = new RestReceiver(\"{{bad\", 200)) {\n            var request = new RestRequest(receiver.getEndpoint());\n            var response = request.post(\"/test\").executeUnparsed();\n            assertEquals(\"{{bad\", response);\n        }\n    }\n\n    @Test\n    void cached() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            var request = new RestRequest(receiver.getEndpoint());\n            request.get(\"/test\").execute();\n            assertFalse(receiver.usedCached());\n            request.get(\"/test\").execute();\n            assertTrue(receiver.usedCached());\n            var anotherRequest = new RestRequest(receiver.getEndpoint());\n            anotherRequest.get(\"/test\").execute();\n            assertTrue(receiver.usedCached());\n        }\n    }\n\n    @Test\n    void cacheFlush() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            var request = new RestRequest(receiver.getEndpoint());\n            request.get(\"/test\").execute();\n            assertFalse(receiver.usedCached());\n            request.post(\"/test\").execute();\n            request.get(\"/test\").execute();\n            assertFalse(receiver.usedCached());\n            var anotherRequest = new RestRequest(receiver.getEndpoint());\n            request.post(\"/test\").execute();\n            anotherRequest.get(\"/test\").execute();\n            assertFalse(receiver.usedCached());\n        }\n    }\n\n    @Test\n    void cacheFlushPartial() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            var request = new RestRequest(receiver.getEndpoint());\n            request.get(\"/test?1\").execute();\n            assertFalse(receiver.usedCached());\n            request.get(\"/test?1\").execute();\n            assertTrue(receiver.usedCached());\n            request.post(\"/test\").execute();\n            request.get(\"/test?1\").execute();\n            assertFalse(receiver.usedCached());\n            request.get(\"/test?1\").execute();\n            assertTrue(receiver.usedCached());\n        }\n    }\n\n    @Test\n    void cachedSeparateAuth() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            var plainRequest = new RestRequest(receiver.getEndpoint());\n            var authRequest1 = new RestRequest(receiver.getEndpoint(), \"id1\", (r) -> List.of(\"user\", \"1\"));\n            var authRequest2 = new RestRequest(receiver.getEndpoint(), \"id2\", (r) -> List.of(\"user\", \"2\"));\n\n            plainRequest.get(\"/test\").execute();\n            assertFalse(receiver.usedCached());\n            authRequest1.get(\"/test\").execute();\n            assertFalse(receiver.usedCached());\n\n            plainRequest.get(\"/test\").execute();\n            assertTrue(receiver.usedCached());\n\n            authRequest2.get(\"/test\").execute();\n            assertFalse(receiver.usedCached());\n            authRequest2.get(\"/test\").execute();\n            assertTrue(receiver.usedCached());\n        }\n    }\n\n    @Test\n    void cachedPagination() throws IOException {\n        var page1 = \"[ { \\\"a\\\": 1 } ]\";\n        var page2 = \"[ { \\\"b\\\": 2 } ]\";\n        try (var receiver = new RestReceiver(List.of(page1))) {\n            var request = new RestRequest(receiver.getEndpoint());\n            var result = request.get(\"/test\").execute();\n            assertEquals(1, result.asArray().size());\n            assertEquals(1, result.asArray().get(0).get(\"a\").asInt());\n            assertFalse(receiver.usedCached());\n\n            var anotherRequest = new RestRequest(receiver.getEndpoint());\n            var anotherResult = anotherRequest.get(\"/test\").execute();\n            assertEquals(1, anotherResult.asArray().size());\n            assertEquals(1, anotherResult.asArray().get(0).get(\"a\").asInt());\n            assertTrue(receiver.usedCached());\n\n            receiver.addPage(page2);\n            var pagedRequest = new RestRequest(receiver.getEndpoint());\n            var pagedResult = pagedRequest.get(\"/test\").execute();\n            assertEquals(2, pagedResult.asArray().size());\n            assertEquals(1, pagedResult.asArray().get(0).get(\"a\").asInt());\n            assertFalse(receiver.usedCached());\n\n            var anotherPagedRequest = new RestRequest(receiver.getEndpoint());\n            var anotherPagedResult = anotherPagedRequest.get(\"/test\").execute();\n            assertEquals(2, anotherPagedResult.asArray().size());\n            assertEquals(1, anotherPagedResult.asArray().get(0).get(\"a\").asInt());\n            assertTrue(receiver.usedCached());\n        }\n    }\n\n    @Test\n    void rawBody() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            var request = new RestRequest(receiver.getEndpoint());\n            request.post(\"/test\").body(\"foo=bar\").execute();\n            var rawRequests = receiver.getRawRequests();\n            assertEquals(1, rawRequests.size());\n            assertEquals(\"foo=bar\", rawRequests.get(0));\n            assertEquals(List.of(), receiver.getRequests());\n        }\n    }\n\n    @Test\n    void jsonBody() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            var request = new RestRequest(receiver.getEndpoint());\n            request.post(\"/test\").body(JSON.object().put(\"foo\", \"bar\")).execute();\n            var requests = receiver.getRequests();\n            assertEquals(1, requests.size());\n            assertEquals(\"bar\", requests.get(0).get(\"foo\").asString());\n            assertEquals(List.of(), receiver.getRawRequests());\n        }\n    }\n\n    @Test\n    void multipleParamsWithSameKey() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            var restRequest = new RestRequest(receiver.getEndpoint());\n            var httpRequest = restRequest.get(\"/test\").param(\"k\", \"v1\").param(\"k\", \"v2\").build();\n            assertEquals(\"k=v1&k=v2\", httpRequest.uri().getQuery());\n        }\n    }\n\n    @Test\n    void multipleParamsWithMultipleKeys() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            var restRequest = new RestRequest(receiver.getEndpoint());\n            var httpRequest = restRequest.get(\"/test\").param(\"k1\", \"v1\").param(\"k2\", \"v2\").build();\n            assertEquals(\"k1=v1&k2=v2\", httpRequest.uri().getQuery());\n        }\n    }\n\n    @Test\n    void multipleParamsWithMultipleKeysWithMultipleValues() throws IOException {\n        try (var receiver = new RestReceiver()) {\n            var restRequest = new RestRequest(receiver.getEndpoint());\n            var httpRequest = restRequest.get(\"/test\")\n                                         .param(\"k1\", \"v1\")\n                                         .param(\"k1\", \"v2\")\n                                         .param(\"k2\", \"v3\")\n                                         .param(\"k2\", \"v4\")\n                                         .build();\n            assertEquals(\"k1=v1&k1=v2&k2=v3&k2=v4\", httpRequest.uri().getQuery());\n        }\n    }\n}\n"
  },
  {
    "path": "network/src/test/java/org/openjdk/skara/network/URIBuilderTests.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.network;\n\nimport java.util.*;\n\nimport org.openjdk.skara.network.*;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass URIBuilderTests {\n    private final String validHost = \"http://www.test.com\";\n\n    @Test\n    void setPathSimple() {\n        var a = URIBuilder.base(validHost).setPath(\"/a\").build();\n        var b = URIBuilder.base(validHost).setPath(\"/b\").build();\n        var aToB = URIBuilder.base(a).setPath(\"/b\").build();\n\n        assertEquals(\"www.test.com\", a.getHost());\n        assertEquals(\"/a\", a.getPath());\n        assertEquals(\"/b\", b.getPath());\n        assertEquals(\"/b\", aToB.getPath());\n    }\n\n    @Test\n    void appendPathSimple() {\n        var a = URIBuilder.base(validHost).setPath(\"/a\").build();\n        var aPlusB = URIBuilder.base(a).appendPath(\"/b\").build();\n\n        assertEquals(\"/a\", a.getPath());\n        assertEquals(\"/a/b\", aPlusB.getPath());\n    }\n\n    @Test\n    void invalidBase() {\n        assertThrows(URIBuilderException.class,\n                     () -> URIBuilder.base(\"x:\\\\y\").build());\n    }\n\n    @Test\n    void invalidSetPath() {\n        assertThrows(URIBuilderException.class,\n                () -> URIBuilder.base(validHost).setPath(\"\\\\c\").build());\n    }\n\n    @Test\n    void invalidAppendPath() {\n        assertThrows(URIBuilderException.class,\n                () -> URIBuilder.base(validHost).appendPath(\"\\\\c\").build());\n    }\n\n    @Test\n    void noHost() {\n        var a = URIBuilder.base(\"file:///a/b/c\").build();\n        assertEquals(\"/a/b/c\", a.getPath());\n    }\n\n    @Test\n    void multipleParamsWithSameKey() {\n        var params = Map.of(\"key\", List.of(\"v1\", \"v2\"));\n        var uri = URIBuilder.base(validHost).setQuery(params).build();\n        assertEquals(\"key=v1&key=v2\", uri.getQuery());\n    }\n\n    @Test\n    void multipleParamsWithDifferentKeys() {\n        var params = new LinkedHashMap<String, List<String>>();\n        params.put(\"k1\", List.of(\"v1\", \"v2\"));\n        params.put(\"k2\", List.of(\"v3\", \"v4\"));\n        var uri = URIBuilder.base(validHost).setQuery(params).build();\n        assertEquals(\"k1=v1&k1=v2&k2=v3&k2=v4\", uri.getQuery());\n    }\n\n    @Test\n    void singleKeyAndValue() {\n        var params = Map.of(\n            \"k1\", List.of(\"v1\")\n        );\n        var uri = URIBuilder.base(validHost).setQuery(params).build();\n        assertEquals(\"k1=v1\", uri.getQuery());\n    }\n}\n"
  },
  {
    "path": "process/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.process'\n    test {\n        requires 'org.junit.jupiter.api'\n        opens 'org.openjdk.skara.process' to 'org.junit.platform.commons'\n    }\n}\n\npublishing {\n    publications {\n        process(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "process/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.process {\n    requires java.logging;\n\n    exports org.openjdk.skara.process;\n}\n"
  },
  {
    "path": "process/src/main/java/org/openjdk/skara/process/Execution.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.process;\n\nimport java.io.*;\nimport java.nio.file.Files;\nimport java.time.*;\nimport java.util.*;\nimport java.util.concurrent.TimeUnit;\nimport java.util.logging.Level;\nimport java.util.logging.Logger;\n\npublic class Execution implements AutoCloseable {\n\n    private final ProcessBuilder processBuilder;\n    private final Process.OutputOption outputOption;\n    private final Duration timeout;\n\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.process\");\n\n    private String cmd;\n    private int status = 0;\n    private File stdoutFile;\n    private File stderrFile;\n\n    private boolean finished;\n    private Result result;\n    private Throwable exception;\n    private java.lang.Process process;\n    private Instant startTime;\n\n    public static class CheckedResult {\n\n        protected final int status;\n        private final String command;\n        private final List<String> stdout;\n        private final List<String> stderr;\n        private final Throwable exception;\n\n        CheckedResult(String command, List<String> stdout, List<String> stderr, int status, Throwable exception) {\n            this.status = status;\n            this.command = command;\n            this.stdout = stdout;\n            this.stderr = stderr;\n            this.exception = exception;\n        }\n\n        public List<String> stdout() {\n            return stdout;\n        }\n\n        public List<String> stderr() {\n            return stderr;\n        }\n\n        public Optional<Throwable> exception() {\n            return Optional.ofNullable(exception);\n        }\n\n        @Override\n        public String toString() {\n            var lines = new ArrayList<String>();\n            lines.add(\"'\" + command + \"' exited with status: \" + status);\n\n            lines.add(\"[stdout]\");\n            for (var line : stdout()) {\n                lines.add(\"> \" + line);\n            }\n            lines.add(\"[stderr]\");\n            for (var line : stderr()) {\n                lines.add(\"> \" + line);\n            }\n\n            return String.join(\"\\n\", lines);\n        }\n    }\n\n    public static class Result extends CheckedResult {\n\n\n        Result(String command, List<String> stdout, List<String> stderr, int status, Throwable exception) {\n            super(command, stdout, stderr, status, exception);\n        }\n\n        public int status() {\n            return status;\n        }\n    }\n\n    private void prepareRedirects() throws IOException {\n\n        if (outputOption == Process.OutputOption.CAPTURE) {\n            stdoutFile = File.createTempFile(\"stdout\", \".txt\");\n            processBuilder.redirectOutput(stdoutFile);\n        } else if (outputOption == Process.OutputOption.INHERIT) {\n            processBuilder.redirectOutput(ProcessBuilder.Redirect.INHERIT);\n        } else {\n            processBuilder.redirectOutput(ProcessBuilder.Redirect.DISCARD);\n        }\n\n        if (outputOption == Process.OutputOption.CAPTURE) {\n            stderrFile = File.createTempFile(\"stderr\", \".txt\");\n            processBuilder.redirectError(stderrFile);\n        } else if (outputOption == Process.OutputOption.INHERIT) {\n            processBuilder.redirectError(ProcessBuilder.Redirect.INHERIT);\n        } else {\n            processBuilder.redirectError(ProcessBuilder.Redirect.DISCARD);\n        }\n\n    }\n\n    private void startProcess() throws IOException {\n        cmd = String.join(\" \", processBuilder.command());\n        log.finer(\"Executing '\" + cmd + \"' in \" + processBuilder.directory());\n\n        prepareRedirects();\n\n        startTime = Instant.now();\n        process = processBuilder.start();\n    }\n\n    private void waitForProcess() throws IOException, InterruptedException {\n        var terminated = this.process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);\n        var duration = Duration.between(startTime, Instant.now());\n        log.log(Level.FINE, \"Executing '\" + String.join(\" \", processBuilder.command())\n                + \"' in \" + processBuilder.directory() + \" took \" + duration, duration);\n        if (!terminated) {\n            log.warning(\"Command '\" + cmd + \"' didn't finish in \" + timeout + \", attempting to terminate...\");\n            this.process.destroyForcibly().waitFor();\n            throw new InterruptedException(\"Command '\" + cmd + \"' didn't finish in \" + timeout + \", terminated\");\n        }\n        status = this.process.exitValue();\n    }\n\n    private List<String> content(File f) {\n        var p = f.toPath();\n        if (Files.exists(p)) {\n            try {\n                return Files.readAllLines(p);\n            } catch (IOException e) {\n                throw new UncheckedIOException(e);\n            }\n        }\n        return new ArrayList<>();\n    }\n\n    private Result createResult() {\n        List<String> stdout = new ArrayList<>();\n        List<String> stderr = new ArrayList<>();\n\n        if (outputOption == Process.OutputOption.CAPTURE) {\n            stdout = content(stdoutFile);\n            if (!stdoutFile.delete()) {\n                log.warning(\"Failed to delete stdout file buffer: \" + stdoutFile.toString());\n            }\n\n            stderr = content(stderrFile);\n            if (!stderrFile.delete()) {\n                log.warning(\"Failed to delete stderr file buffer: \" + stderrFile.toString());\n            }\n        }\n\n        return new Result(cmd, stdout, stderr, status, exception);\n    }\n\n    Execution(ProcessBuilder processBuilder, Process.OutputOption outputOption, Duration timeout) {\n        this.processBuilder = processBuilder;\n        this.outputOption = outputOption;\n        this.timeout = timeout;\n\n        finished = false;\n\n        try {\n            startProcess();\n        } catch (IOException e) {\n            log.throwing(\"Process\", \"execute\", e);\n            finished = true;\n            exception = e;\n            status = -1;\n            result = createResult();\n        }\n    }\n\n    public Result await() {\n        synchronized (this) {\n            if (!finished) {\n                try {\n                    waitForProcess();\n                } catch (IOException | InterruptedException e) {\n                    status = -1;\n                    exception = e;\n                }\n\n                finished = true;\n                result = createResult();\n            }\n        }\n\n        return result;\n    }\n\n    public CheckedResult check() {\n        var ret = await();\n        if (status != 0) {\n            if (exception != null) {\n                throw new RuntimeException(\"Exit status from '\" + cmd + \"': \" + status, exception);\n            } else {\n                throw new RuntimeException(\"Exit status from '\" + cmd + \"': \" + status);\n            }\n        }\n        return ret;\n    }\n\n    @Override\n    public void close() {\n        synchronized (this) {\n            if (!finished) {\n                // FIXME: stop process\n                finished = true;\n                status = -1;\n                result = createResult();\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "process/src/main/java/org/openjdk/skara/process/Process.java",
    "content": "/*\n * Copyright (c) 2018, 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.process;\n\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\npublic class Process {\n    enum OutputOption {\n        CAPTURE,\n        INHERIT,\n        DISCARD\n    }\n\n    public static class Description {\n\n        private static class ProcessBuilderSetup {\n            final List<String> command;\n            final Map<String, String> environment;\n            Path workdir;\n\n            ProcessBuilderSetup(String... command) {\n                this.command = List.of(command);\n                environment = new HashMap<>();\n            }\n        }\n\n        private final OutputOption outputOption;\n        private ProcessBuilderSetup processBuilderSetup;\n        private Duration timeout;\n\n        Description(Process.OutputOption outputOption, String... command) {\n            this.outputOption = outputOption;\n            timeout = Duration.ofHours(6);\n\n            this.processBuilderSetup = new ProcessBuilderSetup(command);\n        }\n\n        private ProcessBuilderSetup getCurrentProcessBuilderSetup() {\n            return processBuilderSetup;\n        }\n\n        public Description environ(String key, String value) {\n            getCurrentProcessBuilderSetup().environment.put(key, value);\n            return this;\n        }\n\n        public Description environ(Map<String, String> keyValueMap) {\n            if (keyValueMap != null) {\n                getCurrentProcessBuilderSetup().environment.putAll(keyValueMap);\n            }\n            return this;\n        }\n\n        public Description timeout(Duration timeout) {\n            this.timeout = timeout;\n            return this;\n        }\n\n        public Description workdir(Path workdir) {\n            getCurrentProcessBuilderSetup().workdir = workdir;\n            return this;\n        }\n\n        public Description workdir(String workdir) {\n            getCurrentProcessBuilderSetup().workdir = Path.of(workdir);\n            return this;\n        }\n\n        public Execution execute() {\n\n            var builder = new ProcessBuilder(processBuilderSetup.command.toArray(new String[0]));\n            builder.environment().putAll(processBuilderSetup.environment);\n            if (processBuilderSetup.workdir != null) {\n                builder.directory(processBuilderSetup.workdir.toFile());\n            }\n\n            return new Execution(builder, outputOption, timeout);\n        }\n\n    }\n\n    /**\n     * Construct a process description that can be executed, with the output captured.\n     * @param command\n     * @return\n     */\n    public static Description capture(String... command) {\n        return new Description(Process.OutputOption.CAPTURE, command);\n    }\n\n    /**\n     * Construct a process description that can be executed, with the output inherited.\n     * @param command\n     * @return\n     */\n    public static Description command(String... command) {\n        return new Description(Process.OutputOption.INHERIT, command);\n    }\n\n    /**\n     * Construct a process description that can be executed, with the output discarded.\n     * @param command\n     * @return\n     */\n    public static Description discard(String... command) {\n        return new Description(Process.OutputOption.DISCARD, command);\n    }\n}\n"
  },
  {
    "path": "process/src/test/java/org/openjdk/skara/process/ProcessTests.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.process;\n\nimport org.junit.jupiter.api.*;\nimport org.junit.jupiter.api.condition.*;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.time.Duration;\nimport java.util.logging.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n@DisabledOnOs(OS.WINDOWS)\nclass ProcessTests {\n\n    private final static String invalidDirectory = \"/askldjfoiuycvbsdf8778\";\n\n    @BeforeAll\n    static void setUp() {\n        Logger log = Logger.getGlobal();\n        log.setLevel(Level.FINER);\n        log = Logger.getLogger(\"org.openjdk.skara.process\");\n        log.setLevel(Level.FINER);\n        ConsoleHandler handler = new ConsoleHandler();\n        handler.setLevel(Level.FINER);\n        log.addHandler(handler);\n    }\n\n\n    @Test\n    void reuseSetup() throws IOException {\n        var tempFile = Files.createTempFile(\"reusesetup\", \"tmp\");\n        var setup = Process.capture(\"rm\", tempFile.toString());\n\n        // Ensure that the command was really executed twice\n        try (var first = setup.execute()) {\n            assertEquals(0, first.await().status());\n        }\n        try (var second = setup.execute()) {\n            assertNotEquals(0, second.await().status());\n        }\n    }\n\n    @Test\n    void noOutput() {\n        try (var p = Process.command(\"ls\", \"/\").execute()) {\n            var result = p.check();\n\n            assertEquals(0, result.stdout().size());\n            assertEquals(0, result.stderr().size());\n        }\n    }\n\n    @Test\n    void timeout() {\n        try (var p = Process.capture(\"sleep\", \"10000\")\n                            .timeout(Duration.ofMillis(1))\n                            .execute()) {\n            var result = p.await();\n            assertEquals(-1, result.status());\n        }\n    }\n\n    @Test\n    void workingDirectory() {\n        try (var p = Process.capture(\"ls\")\n                            .workdir(\"/\")\n                            .execute()) {\n            var result = p.await();\n            assertEquals(0, result.status());\n        }\n        try (var p = Process.capture(\"ls\")\n                            .workdir(invalidDirectory)\n                            .execute()) {\n            var result = p.await();\n            assertNotEquals(0, result.status());\n        }\n    }\n}\n"
  },
  {
    "path": "proxy/build.gradle",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.proxy'\n    test {\n        requires 'org.junit.jupiter.api'\n        opens 'org.openjdk.skara.args' to 'org.junit.platform.commons'\n    }\n}\n\npublishing {\n    publications {\n        proxy(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "proxy/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.proxy {\n    requires java.logging;\n    exports org.openjdk.skara.proxy;\n}\n"
  },
  {
    "path": "proxy/src/main/java/org/openjdk/skara/proxy/HttpProxy.java",
    "content": "/*\n * Copyright (c) 2018, 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.proxy;\n\nimport java.io.IOException;\nimport java.net.*;\nimport java.nio.charset.StandardCharsets;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\npublic class HttpProxy {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.proxy\");\n\n    private static boolean setProxyHostAndPortBasedOn(String protocol, URI uri) {\n        var port = String.valueOf(uri.getPort() == -1 ? 80 : uri.getPort());\n        if (System.getProperty(protocol + \".proxyHost\") == null) {\n            log.fine(\"Setting \" + protocol + \" proxy to \" + uri.getHost() + \":\" + port);\n            System.setProperty(protocol + \".proxyHost\", uri.getHost());\n            System.setProperty(protocol + \".proxyPort\", port);\n            return true;\n        }\n\n        log.fine(\"Not overriding \" + protocol + \" proxy setting. Current value: \" +\n                         System.getProperty(protocol + \".proxyHost\") + \":\" + System.getProperty(protocol + \".proxyPort\"));\n        return false;\n    }\n\n    public static void setup() {\n        setup(null);\n    }\n\n    public static void setup(String argument) {\n        if (argument != null) {\n            if (!argument.startsWith(\"http://\") &&\n                !argument.startsWith(\"https://\")) {\n                // Try to parse it as a http url - we only care about the host and port\n                argument = \"http://\" + argument;\n            }\n\n            try {\n                var uri = new URI(argument);\n                for (var protocol : List.of(\"http\", \"https\")) {\n                    setProxyHostAndPortBasedOn(protocol, uri);\n                }\n                return;\n            } catch (URISyntaxException e) {\n                // pass\n            }\n        }\n\n        try {\n            var pb = new ProcessBuilder(\"git\", \"config\", \"http.proxy\");\n            pb.redirectOutput(ProcessBuilder.Redirect.PIPE);\n            pb.redirectError(ProcessBuilder.Redirect.DISCARD);\n            var p = pb.start();\n\n            var output = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim();\n            var res = p.waitFor();\n            if (res == 0 && !output.isEmpty()) {\n                if (!output.startsWith(\"http://\") && !output.startsWith(\"https://\")) {\n                    // Try to parse it as a http url - we only care about the host and port\n                    output = \"http://\" + output;\n                }\n                var uri = new URI(output);\n                for (var protocol : List.of(\"http\", \"https\")) {\n                    setProxyHostAndPortBasedOn(protocol, uri);\n                }\n                return;\n            }\n        } catch (InterruptedException | IOException | URISyntaxException e) {\n            // pass\n        }\n\n        boolean hasSetProxy = false;\n        for (var key : List.of(\"http_proxy\", \"https_proxy\")) {\n            var value = System.getenv(key);\n            value = value == null ? System.getenv(key.toUpperCase()) : value;\n            if (value != null) {\n                var protocol = key.split(\"_\")[0].toLowerCase();\n                try {\n                    if (!value.startsWith(\"http://\") && !value.startsWith(\"https://\")) {\n                        // Try to parse it as a http url - we only care about the host and port\n                        value = \"http://\" + value;\n                    }\n                    var uri = new URI(value);\n                    hasSetProxy |= setProxyHostAndPortBasedOn(protocol, uri);\n                } catch (URISyntaxException e) {\n                    // pass\n                }\n            }\n        }\n        var no_proxy = System.getenv(\"no_proxy\");\n        no_proxy = no_proxy == null ? System.getenv(\"NO_PROXY\") : no_proxy;\n        if (no_proxy != null) {\n            if (System.getProperty(\"http.nonProxyHosts\") == null || hasSetProxy) {\n                var hosts = Arrays.stream(no_proxy.split(\",\"))\n                                  .map(s -> s.startsWith(\".\") ? \"*\" + s : s)\n                                  .collect(Collectors.toList());\n                System.setProperty(\"http.nonProxyHosts\", String.join(\"|\", hosts));\n                log.fine(\"Setting nonProxyHosts to \" + String.join(\"|\", hosts));\n            } else {\n                log.fine(\"Not overriding nonProxyHosts setting. Current value: \" +\n                                 System.getProperty(\"http.nonProxyHosts\"));\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "settings.gradle",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nrootProject.name = 'skara'\n\nincludeBuild 'gradle/plugins/skara-proxy'\nincludeBuild 'gradle/plugins/skara-module'\nincludeBuild 'gradle/plugins/skara-images'\nincludeBuild 'gradle/plugins/skara-version'\nincludeBuild 'gradle/plugins/skara-reproduce'\n\ninclude 'args'\ninclude 'bot'\ninclude 'ci'\ninclude 'cli'\ninclude 'census'\ninclude 'email'\ninclude 'encoding'\ninclude 'host'\ninclude 'ini'\ninclude 'jcheck'\ninclude 'json'\ninclude 'mailinglist'\ninclude 'metrics'\ninclude 'process'\ninclude 'proxy'\ninclude 'storage'\ninclude 'ssh'\ninclude 'test'\ninclude 'vcs'\ninclude 'webrev'\ninclude 'network'\ninclude 'forge'\ninclude 'issuetracker'\ninclude 'version'\ninclude 'jbs'\ninclude 'xml'\n\ninclude 'bots:bridgekeeper'\ninclude 'bots:censussync'\ninclude 'bots:checkout'\ninclude 'bots:cli'\ninclude 'bots:common'\ninclude 'bots:forward'\ninclude 'bots:hgbridge'\ninclude 'bots:merge'\ninclude 'bots:mirror'\ninclude 'bots:mlbridge'\ninclude 'bots:notify'\ninclude 'bots:pr'\ninclude 'bots:submit'\ninclude 'bots:synclabel'\ninclude 'bots:tester'\ninclude 'bots:testinfo'\ninclude 'bots:topological'\n"
  },
  {
    "path": "skara.gitconfig",
    "content": "# Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n#\n# This code is free software; you can redistribute it and/or modify it\n# under the terms of the GNU General Public License version 2 only, as\n# published by the Free Software Foundation.\n#\n# This code is distributed in the hope that it will be useful, but WITHOUT\n# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n# version 2 for more details (a copy is included in the LICENSE file that\n# accompanied this code).\n#\n# You should have received a copy of the GNU General Public License version\n# 2 along with this work; if not, write to the Free Software Foundation,\n# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n#\n# Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n# or visit www.oracle.com if you need additional information or have any\n# questions.\n\n[alias]\n        skara = ! sh \\\"$(dirname $(git config --get-all include.path | grep 'skara.gitconfig' | tail -1 | sed 's@~@'$HOME'@'))/skara.sh\\\"\n        jcheck = ! git skara jcheck\n        webrev = ! git skara webrev\n        defpath = ! git skara defpath\n        verify-import = ! git skara verify-import\n        openjdk-import = ! git skara openjdk-import\n        fork = ! git skara fork\n        pr = ! git skara pr\n        token = ! git skara token\n        info = ! git skara info\n        translate = ! git skara translate\n        sync = ! git skara sync\n        publish = ! git skara publish\n        proxy = ! git skara proxy\n        trees = ! git skara trees\n        hg-export = ! git skara hg-export\n        backport = ! git skara backport\n\n        tcommit = trees commit\n        tconfig = trees config\n        tdiff = trees diff\n        tlog = trees log\n        tmerge = trees merge\n        tremote = trees remote\n        tpull = trees pull\n        tpush = trees push\n        tcheckout = trees checkout\n        tstatus = trees status\n        ttag = trees tag\n\n        treconfigure = trees treconfigure\n        tdefpath = trees defpath\n        tsync = trees sync\n        tinfo = trees info\n        tpublish = trees publish\n        tskara = trees skara\n        tproxy = trees proxy\n"
  },
  {
    "path": "skara.py",
    "content": "# Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n#\n# This code is free software; you can redistribute it and/or modify it\n# under the terms of the GNU General Public License version 2 only, as\n# published by the Free Software Foundation.\n#\n# This code is distributed in the hope that it will be useful, but WITHOUT\n# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n# version 2 for more details (a copy is included in the LICENSE file that\n# accompanied this code).\n#\n# You should have received a copy of the GNU General Public License version\n# 2 along with this work; if not, write to the Free Software Foundation,\n# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n#\n# Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n# or visit www.oracle.com if you need additional information or have any\n# questions.\n\nimport mercurial\nimport os\nimport os.path\nimport subprocess\nimport sys\nimport shutil\n\ntestedwith = '4.9.2 5.3'\n\ncmdtable = {}\nif hasattr(mercurial, 'registrar') and hasattr(mercurial.registrar, 'command'):\n    command = mercurial.registrar.command(cmdtable)\nelif hasattr(mercurial.cmdutil, 'command'):\n    command = mercurial.cmdutil.command(cmdtable)\nelse:\n    def command(name, options, synopsis):\n        def decorator(func):\n            cmdtable[name] = func, list(options), synopsis\n            return func\n        return decorator\n\ndef _update_if_needed(ui):\n    skara = os.path.dirname(os.path.realpath(__file__))\n    git_skara = os.path.join(skara, 'bin', 'bin', 'git-skara')\n    if not os.path.isfile(git_skara):\n        ui.status(b'Compiling ...\\n')\n        cmd = ['gradlew.bat'] if os.name == 'nt' else ['/bin/sh', 'gradlew']\n        p = subprocess.Popen(cmd, cwd=skara)\n        ret = p.wait()\n        if ret != 0:\n            ui.error(b\"Error: could not compile Skara\\n\")\n            sys.exit(1)\n\n    skara_bin = os.path.join(skara, 'bin')\n    skara_build = os.path.join(skara, 'build')\n    if os.path.isdir(skara_build):\n        if os.path.isdir(skara_bin):\n            shutil.rmtree(skara_bin)\n        shutil.move(skara_build, skara_bin)\n    return git_skara\n\ndef _opts_to_flags(cmd = None, **opts):\n    args = []\n    for k in opts:\n        name = k.replace('_', '-')\n        v = opts[k]\n        if v == True:\n            args.append('--' + name)\n        elif k == b'terse' and cmd == 'status':\n            if v != b'nothing':\n                args.append('--' + name)\n                args.append(str(v))\n        elif v != b'' and v != [] and v != None and v != False:\n            args.append('--' + name)\n            args.append(str(v))\n    return args\n\ndef _skara(ui, args, **opts):\n    git_skara = _update_if_needed(ui)\n    flags = _opts_to_flags(**opts)\n    ret = subprocess.call([git_skara] + args + flags)\n    sys.exit(ret)\n\ndef _trees(ui, command, *args, **opts):\n    git_skara = _update_if_needed(ui)\n    flags = _opts_to_flags(command, **opts)\n    ret = subprocess.call([git_skara] + ['trees', '--mercurial', command] + flags + list(args))\n    sys.exit(ret)\n\nskara_opts = [\n]\n@command(b'skara', skara_opts, b'hg skara <defpath|help|jcheck|version|webrev|update>')\ndef skara(ui, repo, action=None, **opts):\n    \"\"\"\n    Invoke, list or update Mercurial commands from project Skara\n    \"\"\"\n    if action == None:\n        action = 'help'\n    _skara(ui, [action, '--mercurial'], **opts)\n\nwebrev_opts = [\n    (b'r', b'rev', b'', b'Compare against specified revision'),\n    (b'o', b'output', b'', b'Output directory'),\n    (b'u', b'username', b'', b'Use that username instead \"guessing\" one'),\n    (b'',  b'upstream', b'', b'The URL to the upstream repository'),\n    (b't', b'title', b'', b'The title of the webrev'),\n    (b'c', b'cr', b'', b'Include a link to CR (aka bugid) in the main page'),\n    (b'b', b'b', False, b'Do not ignore changes in whitespace'),\n    (b'C', b'no-comments', False, b\"Don't show comments\"),\n    (b'N', b'no-outgoing', False, b\"Do not compare against remote, use only 'status'\"),\n]\n@command(b'webrev', webrev_opts, b'hg webrev')\ndef webrev(ui, repo, **opts):\n    \"\"\"\n    Builds a set of HTML files suitable for doing a\n    code review of source changes via a web page\n    \"\"\"\n    _skara(ui, ['webrev', '--mercurial'], **opts)\n\njcheck_opts = [\n    (b'r', b'rev', b'', b'Check the specified revision or range (default: tip)'),\n    (b'',  b'census', b'', b'Use the specified census (default: https://openjdk.org/census.xml)'),\n    (b'',  b'local', False, b'Run jcheck in \"local\" mode'),\n    (b'',  b'lax', False, b'Check comments, tags and whitespace laxly'),\n    (b's', b'strict', False, b'Check everything')\n]\n@command(b'jcheck', jcheck_opts, b'hg jcheck')\ndef jcheck(ui, repo, **opts):\n    \"\"\"\n    OpenJDK changeset checker\n    \"\"\"\n    _skara(ui, ['jcheck', '--mercurial'], **opts)\n\ndefpath_opts = [\n    (b'u', b'username', b'', b'Username for push URL'),\n    (b's', b'secondary', b'', b'Secondary peer repostiory base URL'),\n    (b'd', b'default', False, b'Use current default path to compute push path'),\n    (b'g', b'gated', False, b'Created gated push URL'),\n    (b'n', b'dry-run', False, b'Do not perform actions, just print output'),\n]\n@command(b'defpath', defpath_opts, b'hg defpath')\ndef defpath(ui, repo, **opts):\n    \"\"\"\n    Examine and manipulate default path settings\n    \"\"\"\n    _skara(ui, ['defpath', '--mercurial'], **opts)\n\n@command(b'tclone', norepo=True)\ndef tclone(ui, source, dest=None, **opts):\n    repo = mercurial.hg.peer(ui, opts, source)\n    trees = sorted(list(repo.listkeys(b'trees').values()))\n    ui.status(b'cloning %s\\n' % source)\n    if mercurial.commands.clone(ui, source, dest, **opts):\n        return True\n\n    dest = os.path.basename(source) if dest == None else dest\n    with open(os.path.join(dest, '.hg', 'files'), 'w') as f:\n        f.write('\\n'.join(trees))\n\n    for t in sorted(trees, key=len):\n        tsource = source + b'/' + t\n        tdest = os.path.join(dest, t)\n\n        ui.status(b'\\n')\n        ui.status(b'cloning %s\\n' % tsource)\n        ui.status(b'destination directory: %s\\n' % tdest)\n        if mercurial.commands.clone(ui, tsource, tdest, **opts):\n            return True\n\n@command(b'treconfigure', [], b'hg treconfigure')\ndef treconfigure(ui, repo, **opts):\n    \"\"\"\n    Reconfigures the trees files for all sub-repositories\n    \"\"\"\n    _trees(ui, 'treconfigure')\n\ndef extsetup(ui):\n    this = sys.modules[__name__]\n    for cmd in [b'commit', b'config', b'diff', b'heads', b'incoming',\n                b'outgoing', b'log', b'merge', b'parents', b'paths',\n                b'pull', b'push', b'status', b'summary', b'update',\n                b'tag', b'tip']:\n        def f(ui, repo, action = cmd, *args, **opts):\n            _trees(ui, action, *args, **opts)\n        tcommand = command(b't' + cmd, [], b'')(f)\n        tcommand.__doc__ = str(getattr(mercurial.commands, cmd).__doc__)\n        cte = mercurial.cmdutil.findcmd(cmd, mercurial.commands.table)[1]\n        cmdtable[b't' + cmd] = (tcommand, cte[1], cte[2])\n\n    f = lambda ui, repo, *args, **opts: _trees(ui, 'defpath', *args, **opts)\n    tdefpath = command(b'tdefpath', defpath_opts, b'hg tdefpath')(f)\n    tdefpath.__doc__ = defpath.__doc__\n\n    cte = mercurial.cmdutil.findcmd(b'clone', mercurial.commands.table)[1]\n    tclone.__doc__ = mercurial.commands.clone.__doc__\n    cmdtable[b'tclone'] = (tclone, cte[1], cte[2])\n"
  },
  {
    "path": "skara.sh",
    "content": "#!/bin/sh\n\n# Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n#\n# This code is free software; you can redistribute it and/or modify it\n# under the terms of the GNU General Public License version 2 only, as\n# published by the Free Software Foundation.\n#\n# This code is distributed in the hope that it will be useful, but WITHOUT\n# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n# version 2 for more details (a copy is included in the LICENSE file that\n# accompanied this code).\n#\n# You should have received a copy of the GNU General Public License version\n# 2 along with this work; if not, write to the Free Software Foundation,\n# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n#\n# Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n# or visit www.oracle.com if you need additional information or have any\n# questions.\n\nDIR=$(dirname \"${0}\")\nOS=$(uname)\n\nif [ \"${OS}\" = \"Linux\" -o \"${OS}\" = \"Darwin\" ]; then\n    if [ ! -x \"${DIR}/bin/bin/git-skara\" ]; then\n        echo \"Compiling ...\"\n        (cd \"${DIR}\" && sh gradlew)\n    fi\nelse\n    if [ ! -f \"${DIR}/bin/bin/git-skara.bat\" ]; then\n        echo \"Compiling ...\"\n        (cd \"${DIR}\" && ./gradlew.bat)\n    fi\n\nfi\n\nif [ -d \"${DIR}/build/cli\" ]; then\n    rm -rf \"${DIR}/bin\"\n    mv \"${DIR}/build/cli\" \"${DIR}/bin\"\nfi\n\nif [ \"${OS}\" = \"Linux\" -o \"${OS}\" = \"Darwin\" ]; then\n    exec \"${DIR}/bin/bin/git-skara\" \"${@}\"\nelse\n    exec \"${DIR}/bin/bin/git-skara.bat\" \"${@}\"\nfi\n"
  },
  {
    "path": "storage/build.gradle",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.storage'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.junit.jupiter.params'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.storage' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':network')\n    implementation project(':host')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':vcs')\n\n    testImplementation project(':test')\n}\n\npublishing {\n    publications {\n        storage(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "storage/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.storage {\n    requires org.openjdk.skara.network;\n    requires org.openjdk.skara.host;\n    requires org.openjdk.skara.forge;\n    requires org.openjdk.skara.vcs;\n    requires java.logging;\n\n    exports org.openjdk.skara.storage;\n}\n"
  },
  {
    "path": "storage/src/main/java/org/openjdk/skara/storage/FileStorage.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.storage;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\n\nclass FileStorage<T> implements Storage<T> {\n    private final Path file;\n    private String old;\n    private String current;\n    private Set<T> deserialized;\n    private StorageSerializer<T> serializer;\n    private StorageDeserializer<T> deserializer;\n\n    FileStorage(Path file, StorageSerializer<T> serializer, StorageDeserializer<T> deserializer) {\n        this.file = file;\n        this.serializer = serializer;\n        this.deserializer = deserializer;\n    }\n\n    @Override\n    public Set<T> current() {\n        if (current == null) {\n            try {\n                current = Files.readString(file);\n            } catch (IOException e) {\n                current = \"\";\n            }\n        }\n        if (old != current) {\n            deserialized = Collections.unmodifiableSet(deserializer.deserialize(current));\n            old = current;\n        }\n        return deserialized;\n    }\n\n    @Override\n    public void put(Collection<T> items) {\n        var updated = serializer.serialize(items, current());\n        if (current.equals(updated)) {\n            return;\n        }\n        try {\n            Files.createDirectories(file.getParent());\n            if (!Files.exists(file)) {\n                Files.createFile(file);\n            }\n            Files.writeString(file, updated);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        current = updated;\n    }\n}\n"
  },
  {
    "path": "storage/src/main/java/org/openjdk/skara/storage/HostedRepositoryStorage.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.storage;\n\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.logging.Level;\nimport java.util.logging.Logger;\n\nclass HostedRepositoryStorage<T> implements Storage<T> {\n    private final HostedRepository hostedRepository;\n    private final String ref;\n    private final String fileName;\n    private final String authorName;\n    private final String authorEmail;\n    private final String message;\n    private final StorageSerializer<T> serializer;\n    private final StorageDeserializer<T> deserializer;\n    private final Repository localRepository;\n\n    private RepositoryStorage<T> repositoryStorage;\n    private Set<T> current;\n    private final static Logger log = Logger.getLogger(\"org.openjdk.skara.storage\");\n\n    HostedRepositoryStorage(HostedRepository repository, Path localStorage, String ref, String fileName, String authorName, String authorEmail, String message, StorageSerializer<T> serializer, StorageDeserializer<T> deserializer) {\n        this.hostedRepository = repository;\n        this.ref = ref;\n        this.fileName = fileName;\n        this.authorEmail = authorEmail;\n        this.authorName = authorName;\n        this.message = message;\n        this.serializer = serializer;\n        this.deserializer = deserializer;\n\n        localRepository = tryMaterialize(repository, localStorage, ref, fileName, authorName, authorEmail, message);\n        repositoryStorage = new RepositoryStorage<>(localRepository, fileName, authorName, authorEmail, message, serializer, deserializer);\n        current = current();\n    }\n\n    private static Repository tryMaterialize(HostedRepository repository, Path localStorage, String ref, String fileName, String authorName, String authorEmail, String message) {\n        int retryCount = 0;\n        IOException lastException = null;\n\n        while (retryCount < 10) {\n            try {\n                try {\n                    return Repository.materialize(localStorage, repository.authenticatedUrl(), \"+\" + ref + \":storage\");\n                } catch (IOException e2) {\n                    // The remote ref may not yet exist\n                    Repository localRepository = Repository.init(localStorage, repository.repositoryType());\n                    if (!localRepository.isEmpty()) {\n                        // If the materialization failed but the local repository already contains data, do not initialize the ref\n                        log.log(Level.WARNING, \"Materialization into existing local repository failed\", e2);\n                        lastException = e2;\n                        retryCount++;\n                        continue;\n                    }\n\n                    log.info(\"Creating initial storage for: \" + ref);\n                    var file = localStorage.resolve(fileName);\n                    Files.createDirectories(file.getParent());\n                    var storage = Files.writeString(localStorage.resolve(fileName), \"\");\n                    localRepository.add(storage);\n                    var firstCommit = localRepository.commit(message, authorName, authorEmail);\n\n                    // If the materialization failed for any other reason than the remote ref not existing, this will fail\n                    localRepository.push(firstCommit, repository.authenticatedUrl(), ref);\n                    return localRepository;\n                }\n            } catch (IOException e) {\n                lastException = e;\n            }\n            retryCount++;\n        }\n        throw new UncheckedIOException(\"Retry count exceeded\", lastException);\n    }\n\n    @Override\n    public Set<T> current() {\n        return repositoryStorage.current();\n    }\n\n    @Override\n    public void put(Collection<T> items) {\n        int retryCount = 0;\n        IOException lastException = null;\n        Hash lastRemoteHash = null;\n\n        while (retryCount < 10) {\n            // Update our local storage\n            repositoryStorage.put(items);\n            var updated = repositoryStorage.current();\n            if (current.equals(updated)) {\n                return;\n            }\n\n            // The local storage has changed, try to push it to the remote\n            try {\n                var updatedHash = localRepository.head();\n                localRepository.push(updatedHash, hostedRepository.authenticatedUrl(), ref);\n                current = updated;\n                return;\n            } catch (IOException e) {\n                lastException = e;\n\n                // Check if the remote has changed\n                try {\n                    var remoteHash = localRepository.fetch(hostedRepository.authenticatedUrl(), ref).orElseThrow();\n                    if (!remoteHash.equals(lastRemoteHash)) {\n                        localRepository.checkout(remoteHash, true);\n                        repositoryStorage = new RepositoryStorage<>(localRepository, fileName, authorName, authorEmail, message, serializer, deserializer);\n                        lastRemoteHash = remoteHash;\n\n                        // We are making progress catching up with remote changes, don't update the retryCount\n                        continue;\n                    }\n                } catch (IOException e1) {\n                    lastException = e1;\n                }\n                retryCount++;\n            }\n        }\n\n        throw new UncheckedIOException(\"Retry count exceeded\", lastException);\n    }\n}\n"
  },
  {
    "path": "storage/src/main/java/org/openjdk/skara/storage/RepositoryStorage.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.storage;\n\nimport org.openjdk.skara.vcs.Repository;\n\nimport java.io.*;\nimport java.nio.file.Files;\nimport java.util.*;\n\nclass RepositoryStorage<T> implements Storage<T> {\n    private final Repository repository;\n    private final String fileName;\n    private final String authorName;\n    private final String authorEmail;\n    private final String message;\n    private final FileStorage<T> fileStorage;\n\n    private Set<T> current;\n\n    RepositoryStorage(Repository repository, String fileName, String authorName, String authorEmail, String message, StorageSerializer<T> serializer, StorageDeserializer<T> deserializer) {\n        this.repository = repository;\n        this.fileName = fileName;\n        this.authorEmail = authorEmail;\n        this.authorName = authorName;\n        this.message = message;\n\n        try {\n            if (!repository.isHealthy()) {\n                repository.reinitialize();\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n\n        try {\n            fileStorage = new FileStorage<>(repository.root().resolve(fileName), serializer, deserializer);\n            current = current();\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public Set<T> current() {\n        return fileStorage.current();\n    }\n\n    @Override\n    public void put(Collection<T> items) {\n        fileStorage.put(items);\n        var updated = current();\n        if (current.equals(updated)) {\n            return;\n        }\n        current = updated;\n        try {\n            var filePath = repository.root().resolve(fileName);\n            repository.add(filePath);\n            repository.commit(message, authorName, authorEmail);\n\n            if (Files.size(filePath) == 0) {\n                throw new IllegalStateException(\"Storage file is empty: \" + filePath);\n            }\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n}\n"
  },
  {
    "path": "storage/src/main/java/org/openjdk/skara/storage/Storage.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.storage;\n\nimport java.util.*;\n\npublic interface Storage<T> {\n    /**\n     * The current set of stored items. Concurrent changes to permanent storage may not be\n     * detected until updates are attempted.\n     * @return\n     */\n    Set<T> current();\n\n    /**\n     * Add new items and/or update existing ones. Flushes to permanent storage if needed. The\n     * Storage instance may not be used concurrently, but the backing storage may have been updated\n     * concurrently from a different instance. In that case the put operation will be retried.\n     * @param item\n     */\n    void put(Collection<T> item);\n\n    default void put(T item) {\n        put(List.of(item));\n    }\n}\n"
  },
  {
    "path": "storage/src/main/java/org/openjdk/skara/storage/StorageBuilder.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.storage;\n\nimport org.openjdk.skara.forge.HostedRepository;\n\nimport java.nio.file.Path;\n\npublic class StorageBuilder<T> {\n    private final String fileName;\n\n    private HostedRepository remoteRepository;\n    private String remoteRef;\n    private String remoteAuthorName;\n    private String remoteAuthorEmail;\n    private String remoteMessage;\n    private StorageSerializer<T> serializer;\n    private StorageDeserializer<T> deserializer;\n\n    /**\n     * Create a StorageBuilder instance that will use the given fileName to store data.\n     * @param fileName\n     * @return\n     */\n    public StorageBuilder(String fileName) {\n        this.fileName = fileName;\n    }\n\n    /**\n     * Set the storage serializer.\n     * @param serializer\n     * @return\n     */\n    public StorageBuilder<T> serializer(StorageSerializer<T> serializer) {\n        this.serializer = serializer;\n        return this;\n    }\n\n    /**\n     * Set the storage deserializer.\n     * @param deserializer\n     * @return\n     */\n    public StorageBuilder<T> deserializer(StorageDeserializer<T> deserializer) {\n        this.deserializer = deserializer;\n        return this;\n    }\n\n    /**\n     * Attach a remote repository to the Storage where any changes will be added as commits.\n     * @param repository\n     * @param ref\n     * @param authorName\n     * @param authorEmail\n     * @param message\n     * @return\n     */\n    public StorageBuilder<T> remoteRepository(HostedRepository repository, String ref, String authorName, String authorEmail, String message) {\n        if (remoteRepository != null) {\n            throw new IllegalArgumentException(\"Can only set a single remote repository\");\n        }\n        remoteRepository = repository;\n        remoteRef = ref;\n        remoteAuthorName = authorName;\n        remoteAuthorEmail = authorEmail;\n        remoteMessage = message;\n        return this;\n    }\n\n    /**\n     * Create a Storage instance.\n     * @param localFolder\n     * @return\n     */\n    public Storage<T> materialize(Path localFolder) {\n        if (remoteRepository != null) {\n            return new HostedRepositoryStorage<>(remoteRepository, localFolder, remoteRef, fileName, remoteAuthorName, remoteAuthorEmail, remoteMessage, serializer, deserializer);\n        } else {\n            return new FileStorage<>(localFolder.resolve(fileName), serializer, deserializer);\n        }\n    }\n}\n"
  },
  {
    "path": "storage/src/main/java/org/openjdk/skara/storage/StorageDeserializer.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.storage;\n\nimport java.util.Set;\n\npublic interface StorageDeserializer<T> {\n    Set<T> deserialize(String serialized);\n}\n"
  },
  {
    "path": "storage/src/main/java/org/openjdk/skara/storage/StorageSerializer.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.storage;\n\nimport java.util.*;\n\npublic interface StorageSerializer<T> {\n    String serialize(Collection<T> added, Set<T> existing);\n}\n"
  },
  {
    "path": "storage/src/test/java/org/openjdk/skara/storage/FileStorageTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.storage;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.stream.*;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nclass FileStorageTests {\n    private FileStorage<String> stringStorage(Path fileName) {\n        return new FileStorage<>(fileName, (added, cur) -> Stream.concat(cur.stream(), added.stream())\n                                                                 .sorted()\n                                                                 .collect(Collectors.joining(\";\")),\n                                 cur -> Arrays.stream(cur.split(\";\"))\n                                              .filter(str -> !str.isEmpty())\n                                              .collect(Collectors.toSet()));\n    }\n\n    @Test\n    void simple() throws IOException {\n        var tmpFile = Files.createTempFile(\"filestorage\", \".txt\");\n        var storage = stringStorage(tmpFile);\n\n        assertEquals(Set.of(), storage.current());\n        storage.put(\"hello there\");\n        assertEquals(Set.of(\"hello there\"), storage.current());\n\n        Files.delete(tmpFile);\n    }\n\n    @Test\n    void multiple() throws IOException {\n        var tmpFile = Files.createTempFile(\"filestorage\", \".txt\");\n        var storage = stringStorage(tmpFile);\n\n        assertEquals(Set.of(), storage.current());\n        storage.put(List.of(\"hello\", \"there\"));\n        assertEquals(Set.of(\"hello\", \"there\"), storage.current());\n\n        Files.delete(tmpFile);\n    }\n\n    @Test\n    void retained() throws IOException {\n        var tmpFile = Files.createTempFile(\"filestorage\", \".txt\");\n        var storage = stringStorage(tmpFile);\n\n        assertEquals(Set.of(), storage.current());\n        storage.put(\"hello there\");\n        assertEquals(Set.of(\"hello there\"), storage.current());\n\n        var newStorage = stringStorage(tmpFile);\n        assertEquals(Set.of(\"hello there\"), newStorage.current());\n\n        Files.delete(tmpFile);\n    }\n\n    private static class CountingDeserializer implements StorageDeserializer<String> {\n        private int counter = 0;\n\n        CountingDeserializer() {\n        }\n\n        int counter() {\n            return counter;\n        }\n\n        @Override\n        public Set<String> deserialize(String serialized) {\n            counter++;\n            return Arrays.stream(serialized.split(\";\"))\n                         .filter(str -> !str.isEmpty())\n                         .collect(Collectors.toSet());\n        }\n    }\n\n    @Test\n    void cached() throws IOException {\n        var tmpFile = Files.createTempFile(\"filestorage\", \".txt\");\n        var deserializer = new CountingDeserializer();\n        var storage = new FileStorage<String>(tmpFile,\n                                              (added, cur) -> Stream.concat(cur.stream(), added.stream())\n                                                                    .sorted()\n                                                                    .collect(Collectors.joining(\";\")),\n                                              deserializer);\n        assertEquals(Set.of(), storage.current());\n        assertEquals(1, deserializer.counter());\n\n        // Another call to current() should not cause deseralization\n        storage.current();\n        assertEquals(1, deserializer.counter());\n\n        // Updated content should cause deseralization\n        storage.put(\"hello there\");\n        assertEquals(Set.of(\"hello there\"), storage.current());\n        assertEquals(2, deserializer.counter());\n\n        // Another call to current() should not cause deseralization\n        assertEquals(Set.of(\"hello there\"), storage.current());\n        assertEquals(2, deserializer.counter());\n\n        Files.delete(tmpFile);\n    }\n}\n"
  },
  {
    "path": "storage/src/test/java/org/openjdk/skara/storage/HostedRepositoryStorageTests.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.storage;\n\nimport org.junit.jupiter.api.*;\nimport org.openjdk.skara.test.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.IOException;\nimport java.util.*;\nimport java.util.stream.*;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class HostedRepositoryStorageTests {\n    String serializer(Collection<String> added, Set<String> existing) {\n        return Stream.concat(added.stream(), existing.stream())\n                .distinct()\n                .sorted()\n                .collect(Collectors.joining(\"\\n\"));\n    }\n\n    Set<String> deserializer(String serialized) {\n        return serialized.lines().collect(Collectors.toSet());\n    }\n\n    @Test\n    void failedMaterialization(TestInfo testInfo) throws IOException {\n        try (var credentials = new HostCredentials(testInfo);\n                var tempFolder = new TemporaryDirectory()) {\n            var repo = credentials.getHostedRepository();\n            var storage = new HostedRepositoryStorage<>(repo, tempFolder.path(), \"master\", \"test.txt\",\n                                                        \"duke\", \"duke@openjdk.java.org\",\n                                                        \"Updated storage\", this::serializer, this::deserializer);\n            storage.put(List.of(\"a\", \"b\"));\n\n            // Corrupt the destination path and materialize again\n            var localRepo = TestableRepository.init(tempFolder.path(), VCS.GIT);\n            localRepo.checkout(new Branch(\"storage\"));\n            assertThrows(RuntimeException.class, () -> new HostedRepositoryStorage<>(repo, tempFolder.path(), \"master\", \"test.txt\",\n                                                                                     \"duke\", \"duke@openjdk.java.org\",\n                                                                                     \"Updated storage\", this::serializer, this::deserializer));\n        }\n    }\n}\n"
  },
  {
    "path": "storage/src/test/java/org/openjdk/skara/storage/RepositoryStorageTests.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.storage;\n\nimport org.junit.jupiter.api.BeforeAll;\nimport org.openjdk.skara.test.TestableRepository;\nimport org.openjdk.skara.vcs.*;\n\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.EnumSource;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.util.*;\nimport java.util.stream.*;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assumptions.assumeFalse;\n\nclass RepositoryStorageTests {\n    private static boolean hgAvailable = true;\n\n    @BeforeAll\n    static void checkHgAvailability() {\n        try {\n            var pb = new ProcessBuilder(\"hg\", \"--version\");\n            pb.redirectErrorStream(true);\n            var process = pb.start();\n            process.waitFor();\n            hgAvailable = (process.exitValue() == 0);\n        } catch (Exception e) {\n            hgAvailable = false;\n        }\n    }\n\n    private RepositoryStorage<String> stringStorage(Repository repository) {\n        return new RepositoryStorage<>(repository, \"db.txt\", \"Duke\", \"duke@openjdk.org\", \"Test update\",\n                                       (added, cur) -> Stream.concat(cur.stream(), added.stream())\n                                                             .sorted()\n                                                             .collect(Collectors.joining(\";\")),\n                                       cur -> Arrays.stream(cur.split(\";\"))\n                                                    .filter(str -> !str.isEmpty())\n                                                    .collect(Collectors.toSet()));\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void simple(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        var tmpDir = Files.createTempDirectory(\"repositorystorage\");\n        var repository = TestableRepository.init(tmpDir, vcs);\n        var storage = stringStorage(repository);\n\n        assertEquals(Set.of(), storage.current());\n        storage.put(\"hello there\");\n        assertEquals(Set.of(\"hello there\"), storage.current());\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void multiple(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        var tmpDir = Files.createTempDirectory(\"repositorystorage\");\n        var repository = TestableRepository.init(tmpDir, vcs);\n        var storage = stringStorage(repository);\n\n        assertEquals(Set.of(), storage.current());\n        storage.put(Set.of(\"hello\", \"there\"));\n        assertEquals(Set.of(\"hello\", \"there\"), storage.current());\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void retained(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        var tmpDir = Files.createTempDirectory(\"repositorystorage\");\n        var repository = TestableRepository.init(tmpDir, vcs);\n        var storage = stringStorage(repository);\n\n        assertEquals(Set.of(), storage.current());\n        storage.put(\"hello there\");\n        assertEquals(Set.of(\"hello there\"), storage.current());\n\n        var newRepository = Repository.get(tmpDir).orElseThrow();\n        var newStorage = stringStorage(repository);\n        assertEquals(Set.of(\"hello there\"), newStorage.current());\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void duplicates(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        var tmpDir = Files.createTempDirectory(\"repositorystorage\");\n        var repository = TestableRepository.init(tmpDir, vcs);\n        var storage = stringStorage(repository);\n\n        assertEquals(Set.of(), storage.current());\n        storage.put(\"hello there\");\n        assertEquals(Set.of(\"hello there\"), storage.current());\n        storage.put(\"hello there\");\n        assertEquals(Set.of(\"hello there\"), storage.current());\n        storage.put(\"hello there again\");\n        assertEquals(Set.of(\"hello there\", \"hello there again\"), storage.current());\n    }\n}\n"
  },
  {
    "path": "test/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.test'\n}\n\ndependencies {\n    implementation project(':ci')\n    implementation project(':json')\n    implementation project(':census')\n    implementation project(':vcs')\n    implementation project(':bot')\n    implementation project(':host')\n    implementation project(':network')\n    implementation project(':forge')\n    implementation project(':issuetracker')\n    implementation project(':email')\n    implementation project(':mailinglist')\n    implementation project(':proxy')\n    implementation project(':metrics')\n\n    implementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'\n    implementation 'org.junit.jupiter:junit-jupiter-params:5.8.2'\n    runtimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'\n}\n\npublishing {\n    publications {\n        test(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.test {\n    requires java.logging;\n    requires jdk.httpserver;\n\n    requires org.openjdk.skara.census;\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.bot;\n    requires org.openjdk.skara.json;\n    requires org.openjdk.skara.host;\n    requires org.openjdk.skara.network;\n    requires org.openjdk.skara.issuetracker;\n    requires org.openjdk.skara.forge;\n    requires org.openjdk.skara.email;\n    requires org.openjdk.skara.mailinglist;\n    requires org.openjdk.skara.proxy;\n\n    requires org.junit.jupiter.api;\n\n    exports org.openjdk.skara.test;\n\n    provides org.junit.jupiter.api.extension.Extension\n        with org.openjdk.skara.test.DisableAllBotsTestsOnWindows;\n}\n\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/CensusBuilder.java",
    "content": "/*\n * Copyright (c) 2018, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.util.logging.Logger;\n\n/**\n * Generates a valid census repository for use in tests. The possible structure\n * is limited compared to a real census. A default user with forge ID 0 is always\n * present as \"lead\" in the default group 'main' and default project 'test'.\n * <p>\n * Users can be added either directly to the default project with a given role, or\n * as just generic users without any project roles.\n */\npublic class CensusBuilder {\n    private final String namespace;\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.test.utils\");;\n\n    public record User(String forgeId, String name, String fullName) {\n    }\n\n    private final Map<String, User> users = new HashMap<>();\n    private int userIndex = 1;\n\n    private static class Project {\n        private User lead;\n        private final List<User> authors = new ArrayList<>();\n        private final List<User> committers = new ArrayList<>();\n        private final List<User> reviewers = new ArrayList<>();\n    }\n    private final Project defaultProject;\n    private final Map<String, Project> projects = new HashMap<>();\n\n    private static class Group {\n        private User lead;\n        private final List<User> members = new ArrayList<>();\n    }\n    private final Group defaultGroup;\n    private final Map<String, Group> groups = new HashMap<>();\n\n\n    /**\n     * Creates a basic CensusBuilder with an implicit default project named\n     * \"test\", a default group named \"main\" and a default user with lead role\n     * in both of those.\n     */\n    public static CensusBuilder create(String namespace) {\n        return new CensusBuilder(namespace);\n    }\n\n    private CensusBuilder(String namespace) {\n        this.namespace = namespace;\n\n        defaultProject = new Project();\n        projects.put(\"test\", defaultProject);\n\n        defaultGroup = new Group();\n        groups.put(\"main\", defaultGroup);\n\n        var lead = new User(\"0\", \"integrationlead\", \"Generated Lead\");\n        users.put(lead.forgeId, lead);\n        defaultProject.lead = lead;\n        defaultGroup.lead = lead;\n    }\n\n    /**\n     * Creates new user and adds it to the default group and as author in the\n     * default project.\n     */\n    public CensusBuilder addAuthor(String forgeId) {\n        var user = createUser(forgeId, \"integrationauthor\", \"Generated Author\");\n        defaultProject.authors.add(user);\n        return this;\n    }\n\n    /**\n     * Creates new user and adds it to the default group and as committer in the\n     * default project.\n     */\n    public CensusBuilder addCommitter(String forgeId) {\n        var user = createUser(forgeId, \"integrationcommitter\", \"Generated Committer\");\n        defaultProject.committers.add(user);\n        return this;\n    }\n\n    /**\n     * Creates new user and adds it to the default group and as reviewer in the\n     * default project.\n     */\n    public CensusBuilder addReviewer(String forgeId) {\n        var user = createUser(forgeId, \"integrationreviewer\", \"Generated Reviewer\");\n        defaultProject.reviewers.add(user);\n        return this;\n    }\n\n    /**\n     * Creates new user with custom names and adds it to the default group, but\n     * not to any project.\n     */\n    public CensusBuilder addUser(String forgeId, String name, String fullName) {\n        var user = new User(forgeId, name, fullName);\n        userIndex++;\n        users.put(forgeId, user);\n        defaultGroup.members.add(user);\n        return this;\n    }\n\n    private User createUser(String forgeId, String baseName, String baseFullName) {\n        var user = new User(forgeId, baseName + userIndex, baseFullName + \" \" + userIndex);\n        userIndex++;\n        users.put(forgeId, user);\n        defaultGroup.members.add(user);\n        return user;\n    }\n\n    /**\n     * Adds existing user to project as author\n     */\n    public CensusBuilder addAuthor(String forgeId, String project) {\n        var user = users.get(forgeId);\n        projects.get(project).authors.add(user);\n        return this;\n    }\n\n    /**\n     * Adds existing user to project as committer\n     */\n    public CensusBuilder addCommitter(String forgeId, String project) {\n        var user = users.get(forgeId);\n        projects.get(project).committers.add(user);\n        return this;\n    }\n\n    /**\n     * Adds existing user to project as reviewer\n     */\n    public CensusBuilder addReviewer(String forgeId, String project) {\n        var user = users.get(forgeId);\n        projects.get(project).reviewers.add(user);\n        return this;\n    }\n\n    /**\n     * Adds a new project with the existing user set as lead\n     */\n    public CensusBuilder addProject(String name, String leadForgeId) {\n        Project project = new Project();\n        project.lead = users.get(leadForgeId);\n        projects.put(name, project);\n        return this;\n    }\n\n    public User user(String forgeId) {\n        return users.get(forgeId);\n    }\n\n    private void writeContributor(PrintWriter writer, User user) {\n        writer.print(\"  <contributor username=\\\"\");\n        writer.print(user.name);\n        writer.print(\"\\\" full-name=\\\"\");\n        writer.print(user.fullName);\n        writer.print(\"\\\" />\");\n        writer.println();\n    }\n\n    private void writeMember(PrintWriter writer, User user, String role) {\n        writer.print(\"  <\");\n        writer.print(role);\n        writer.print(\" username=\\\"\");\n        writer.print(user.name);\n        writer.print(\"\\\" />\");\n        writer.println();\n    }\n\n    private void writeRole(PrintWriter writer, User user, String role) {\n        writer.print(\"  <\");\n        writer.print(role);\n        writer.print(\" username=\\\"\");\n        writer.print(user.name);\n        writer.print(\"\\\" since=\\\"0\\\" />\");\n        writer.println();\n    }\n\n    private void writeMapping(PrintWriter writer, User user) {\n        writer.print(\"  <user id=\\\"\");\n        writer.print(user.forgeId);\n        writer.print(\"\\\" census=\\\"\");\n        writer.print(user.name);\n        writer.print(\"\\\" />\");\n        writer.println();\n    }\n\n    private void generateContributors(Path folder) throws IOException {\n        Files.createDirectories(folder);\n        try (var writer = new PrintWriter(new FileWriter(folder.resolve(\"contributors.xml\").toFile()))) {\n            writer.println(\"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\");\n            writer.println(\"<contributors>\");\n            users.values().forEach(user -> writeContributor(writer, user));\n            writer.println(\"</contributors>\");\n        }\n    }\n\n    private void generateGroup(Path folder) throws IOException {\n        var groupFolder = folder.resolve(\"groups\");\n        Files.createDirectories(groupFolder);\n        for (var groupEntry : groups.entrySet()) {\n            var name = groupEntry.getKey();\n            var group = groupEntry.getValue();\n            try (var writer = new PrintWriter(new FileWriter(groupFolder.resolve(name + \".xml\").toFile()))) {\n                writer.println(\"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\");\n                writer.println(\"<group name=\\\"\" + name + \"\\\" full-name=\\\"\" + name + \" group\\\">\");\n                writeMember(writer, group.lead, \"lead\");\n                group.members.forEach(user -> writeMember(writer, user, \"member\"));\n                writer.println(\"</group>\");\n            }\n        }\n    }\n\n    private void generateProject(Path folder) throws IOException {\n        var projectFolder = folder.resolve(\"projects\");\n        Files.createDirectories(projectFolder);\n        for (var projectEntry : projects.entrySet()) {\n            String name = projectEntry.getKey();\n            var project = projectEntry.getValue();\n            try (var writer = new PrintWriter(new FileWriter(projectFolder.resolve(name + \".xml\").toFile()))) {\n                writer.println(\"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\");\n                writer.println(\"<project name=\\\"\" + name + \"\\\" full-name=\\\"\" + name + \" Project\\\" sponsor=\\\"main\\\">\");\n                writeRole(writer, project.lead, \"lead\");\n                project.authors.forEach(user -> writeRole(writer, user, \"author\"));\n                project.committers.forEach(user -> writeRole(writer, user, \"committer\"));\n                project.reviewers.forEach(user -> writeRole(writer, user, \"reviewer\"));\n                writer.println(\"</project>\");\n            }\n        }\n    }\n\n    private void generateNamespace(Path folder) throws IOException {\n        var namespaceFolder = folder.resolve(\"namespaces\");\n        Files.createDirectories(namespaceFolder);\n        try (var writer = new PrintWriter(new FileWriter(namespaceFolder.resolve(namespace + \".xml\").toFile()))) {\n            writer.println(\"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\");\n            writer.println(\"<namespace name=\\\"\" + namespace + \"\\\">\");\n            users.values().forEach(user -> writeMapping(writer, user));\n            writer.println(\"</namespace>\");\n        }\n    }\n\n    private void generateVersion(Path folder) throws IOException {\n        Files.createDirectories(folder);\n        try (var writer = new PrintWriter(new FileWriter(folder.resolve(\"version.xml\").toFile()))) {\n            writer.println(\"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\");\n            writer.println(\"<version format=\\\"1\\\" timestamp=\\\"2018-11-21T20:49:40Z\\\" />\");\n        }\n    }\n\n    public HostedRepository build() {\n        try {\n            var host = TestHost.createNew(List.of(HostUser.create(1, \"cu\", \"Census User\")));\n            var repository = host.repository(\"census\").orElseThrow();\n            var folder = Files.createTempDirectory(\"censusbuilder\");\n            var localRepository = Repository.init(folder, VCS.GIT);\n\n            log.fine(\"Generating census XML files in \" + folder);\n            generateGroup(folder);\n            generateProject(folder);\n            generateContributors(folder);\n            generateNamespace(folder);\n            generateVersion(folder);\n\n            localRepository.add(folder);\n            var hash = localRepository.commit(\"Generated census\", \"Census User\", \"cu@test.test\");\n            localRepository.push(hash, repository.authenticatedUrl(), Branch.defaultFor(VCS.GIT).name(), true);\n            return repository;\n\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/CheckableRepository.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.util.Set;\n\npublic class CheckableRepository {\n    private static String markerLine = \"The very first line\\n\";\n\n    private static Path checkableFile(Path path) throws IOException {\n        try (var checkable = Files.newBufferedReader(path.resolve(\".checkable/name.txt\"))) {\n            var checkableName = checkable.readLine();\n            return path.resolve(checkableName);\n        }\n    }\n\n    public static Repository init(Path path, VCS vcs, Path appendableFilePath, Set<String> errorChecks, Set<String> warningChecks, String version) throws IOException {\n        var repo = Repository.init(path, vcs);\n\n        Files.createDirectories(path.resolve(\".checkable\"));\n        try (var output = Files.newBufferedWriter(path.resolve(\".checkable/name.txt\"))) {\n            output.write(appendableFilePath.toString());\n        }\n        repo.add(path.resolve(\".checkable/name.txt\"));\n\n        var initialFile = path.resolve(appendableFilePath);\n        try (var output = Files.newBufferedWriter(initialFile)) {\n            output.append(markerLine);\n        }\n        repo.add(initialFile);\n\n        Files.createDirectories(path.resolve(\".jcheck\"));\n        var checkConf = path.resolve(\".jcheck/conf\");\n        try (var output = Files.newBufferedWriter(checkConf)) {\n            output.append(\"[general]\\n\");\n            output.append(\"project=test\\n\");\n            output.append(\"jbs=tstprj\\n\");\n            if (version != null) {\n                output.append(\"version=\");\n                output.append(version);\n                output.append(\"\\n\");\n            }\n            output.append(\"\\n\");\n            output.append(\"[checks]\\n\");\n            output.append(\"error=\");\n            output.append(String.join(\",\", errorChecks));\n            output.append(\"\\n\");\n            output.append(\"warning=\");\n            output.append(String.join(\",\", warningChecks));\n            output.append(\"\\n\\n\");\n            output.append(\"[census]\\n\");\n            output.append(\"version=0\\n\");\n            output.append(\"domain=openjdk.org\\n\");\n            output.append(\"\\n\");\n            output.append(\"[checks \\\"whitespace\\\"]\\n\");\n            output.append(\"files=.*\\\\.txt\\n\");\n            output.append(\"\\n\");\n            output.append(\"[checks \\\"reviewers\\\"]\\n\");\n            output.append(\"reviewers=1\\n\");\n            output.append(\"\\n\");\n            output.append(\"[checks \\\"copyright\\\"]\\n\");\n            output.append(\"files=.*\\\\.txt\\n\");\n            output.append(\"oracle_locator=.*Copyright \\\\(c\\\\)(.*)Oracle and/or its affiliates\\\\. All rights reserved\\\\.\\n\");\n            output.append(\"oracle_validator=.*Copyright \\\\(c\\\\) (\\\\d{4})(?:, (\\\\d{4}))?, Oracle and/or its affiliates\\\\. All rights reserved\\\\.\\n\");\n            output.append(\"oracle_required=true\\n\");\n            output.append(\"redhat_locator=.*Copyright \\\\(c\\\\)(.*)Red Hat, Inc\\\\.\\n\");\n            output.append(\"redhat_validator=.*Copyright \\\\(c\\\\) (\\\\d{4})(?:, (\\\\d{4}))?, Red Hat, Inc\\\\.\\n\");\n        }\n        repo.add(checkConf);\n\n        repo.commit(\"Initial commit\", \"testauthor\", \"ta@none.none\");\n\n        return repo;\n    }\n\n    public static Repository init(Path path, VCS vcs, Path appendableFilePath, Set<String> errorChecks, String version) throws IOException {\n        return init(path, vcs, appendableFilePath, errorChecks, Set.of(), version);\n    }\n\n    public static Repository init(Path path, VCS vcs, Path appendableFilePath) throws IOException {\n        return init(path, vcs, appendableFilePath, Set.of(\"author\", \"reviewers\", \"whitespace\"), Set.of(), \"0.1\");\n    }\n\n    public static Repository init(Path path, VCS vcs) throws IOException {\n        return init(path, vcs, Path.of(\"appendable.txt\"));\n    }\n\n    public static Hash appendAndCommit(Repository repo) throws IOException {\n        return appendAndCommit(repo, \"This is a new line\");\n    }\n\n    public static Hash appendAndCommit(Repository repo, String body) throws IOException {\n        return appendAndCommit(repo, body, \"Append commit\");\n    }\n\n    public static Hash appendAndCommit(Repository repo, String body, String message) throws IOException {\n        return appendAndCommit(repo, body, message, \"testauthor\", \"ta@none.none\");\n    }\n\n    public static Hash appendAndCommit(Repository repo, String body, String message, String authorName, String authorEmail) throws IOException {\n        return appendAndCommit(repo, body, message, authorName, authorEmail, authorName, authorEmail);\n    }\n\n    public static Hash appendAndCommit(Repository repo, String body, String message, String authorName, String authorEmail,\n                                       String committerName, String committerEmail) throws IOException {\n        var file = checkableFile(repo.root());\n        try (var output = Files.newBufferedWriter(file, StandardOpenOption.APPEND)) {\n            output.append(body);\n            output.append(\"\\n\");\n        }\n        repo.add(file);\n\n        return repo.commit(message, authorName, authorEmail, committerName, committerEmail);\n    }\n\n    public static Hash replaceAndCommit(Repository repo, String body) throws IOException {\n        return replaceAndCommit(repo, body, \"Replace commit\", \"testauthor\", \"ta@none.none\");\n    }\n\n    public static Hash replaceAndCommit(Repository repo, String body, String message, String authorName, String authorEmail) throws IOException {\n        var file = checkableFile(repo.root());\n        try (var output = Files.newBufferedWriter(file)) {\n            output.append(markerLine);\n            output.append(body);\n            output.append(\"\\n\");\n        }\n        repo.add(file);\n\n        return repo.commit(message, authorName, authorEmail);\n    }\n\n    public static boolean hasBeenEdited(Repository repo) throws IOException {\n        var file = checkableFile(repo.root());\n        var lines = Files.readAllLines(file);\n        return lines.size() > 1;\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/DisableAllBotsTestsOnWindows.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport static org.junit.jupiter.api.extension.ConditionEvaluationResult.disabled;\nimport static org.junit.jupiter.api.extension.ConditionEvaluationResult.enabled;\n\nimport org.junit.jupiter.api.condition.OS;\nimport org.junit.jupiter.api.extension.ConditionEvaluationResult;\nimport org.junit.jupiter.api.extension.ExecutionCondition;\nimport org.junit.jupiter.api.extension.ExtensionContext;\n\npublic class DisableAllBotsTestsOnWindows implements ExecutionCondition {\n    @Override\n    public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {\n        if (!OS.WINDOWS.isCurrentOs()) {\n            return enabled(\"Non-Windows OS\");\n        }\n        var test = context.getRequiredTestClass();\n        var bots = test.getPackageName().startsWith(\"org.openjdk.skara.bots.\");\n        return bots ? disabled(\"All bots tests are disabled on Windows\") : enabled(\"Non-bots test\");\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/EnabledIfTestProperties.java",
    "content": "/*\n * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\nimport java.lang.annotation.ElementType;\nimport java.util.ArrayList;\n\nimport org.junit.jupiter.api.extension.ConditionEvaluationResult;\nimport org.junit.jupiter.api.extension.ExecutionCondition;\nimport org.junit.jupiter.api.extension.ExtensionContext;\nimport org.junit.jupiter.api.extension.ExtendWith;\n\n@Retention(RetentionPolicy.RUNTIME)\n@Target(ElementType.METHOD)\n@ExtendWith(EnabledIfTestProperties.EnabledIfTestPropertiesCondition.class)\npublic @interface EnabledIfTestProperties {\n    String[] value() default \"\";\n    class EnabledIfTestPropertiesCondition implements ExecutionCondition {\n        @Override\n        public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {\n            var props = TestProperties.load();\n            if (!props.arePresent()) {\n                return ConditionEvaluationResult.disabled(\"No '\" + TestProperties.FILENAME + \"' file found\");\n            }\n            var annotation = context.getTestMethod().orElseThrow().getAnnotation(EnabledIfTestProperties.class);\n            var missing = new ArrayList<String>();\n            for (var val : annotation.value()) {\n                if (!props.contains(val)) {\n                    missing.add(val);\n                }\n            }\n            if (!missing.isEmpty()) {\n                return ConditionEvaluationResult.disabled(\"Missing the following keys in the \" + TestProperties.FILENAME +\n                                                          \" file: \" + String.join(\", \", missing));\n            }\n            return ConditionEvaluationResult.enabled(\"All required properties are present in the file \" + TestProperties.FILENAME);\n        }\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/HostCredentials.java",
    "content": "/*\n * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.*;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.proxy.HttpProxy;\nimport org.openjdk.skara.vcs.*;\n\nimport org.junit.jupiter.api.TestInfo;\n\nimport java.io.*;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.*;\nimport java.time.*;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\nimport java.util.logging.Logger;\n\npublic class HostCredentials implements AutoCloseable {\n    private final String testName;\n    private final Credentials credentials;\n    private final List<PullRequest> pullRequestsToBeClosed = new ArrayList<>();\n    private final List<IssueTrackerIssue> issuesToBeClosed = new ArrayList<>();\n    private HostedRepository credentialsLock;\n    private int nextHostIndex;\n\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.test\");\n\n    static {\n        HttpProxy.setup();\n    }\n\n    private interface Credentials {\n        Forge createRepositoryHost(int userIndex);\n        IssueTracker createIssueHost(int userIndex);\n        HostedRepository getHostedRepository(Forge host, String name);\n        IssueProject getIssueProject(IssueTracker host);\n        String getNamespaceName();\n        default void close() {}\n    }\n\n    private static class GitHubCredentials implements Credentials {\n        private final JSONObject config;\n        private final Path configDir;\n\n        GitHubCredentials(JSONObject config, Path configDir) {\n            this.config = config;\n            this.configDir = configDir;\n        }\n\n        @Override\n        public Forge createRepositoryHost(int userIndex) {\n            var hostUri = URIBuilder.base(config.get(\"host\").asString()).build();\n            var apps = config.get(\"apps\").asArray();\n            var key = configDir.resolve(apps.get(userIndex).get(\"key\").asString());\n            try {\n                var keyContents = Files.readString(key);\n                var pat = new Credential(apps.get(userIndex).get(\"id\").asString() + \";\" +\n                                                 apps.get(userIndex).get(\"installation\").asString(),\n                                         keyContents);\n                return Forge.from(\"github\", hostUri, pat, null);\n            } catch (IOException e) {\n                throw new RuntimeException(\"Cannot read private key: \" + key);\n            }\n        }\n\n        @Override\n        public IssueTracker createIssueHost(int userIndex) {\n            throw new RuntimeException(\"not implemented yet\");\n        }\n\n        @Override\n        public HostedRepository getHostedRepository(Forge host, String name) {\n            return host.repository(name != null ? name : config.get(\"project\").asString()).orElseThrow();\n        }\n\n        @Override\n        public IssueProject getIssueProject(IssueTracker host) {\n            return host.project(config.get(\"project\").asString());\n        }\n\n        @Override\n        public String getNamespaceName() {\n            return config.get(\"namespace\").asString();\n        }\n    }\n\n    private static class GitLabCredentials implements Credentials {\n        private final JSONObject config;\n\n        GitLabCredentials(JSONObject config) {\n            this.config = config;\n        }\n\n        @Override\n        public Forge createRepositoryHost(int userIndex) {\n            var hostUri = URIBuilder.base(config.get(\"host\").asString()).build();\n            var users = config.get(\"users\").asArray();\n            var pat = new Credential(users.get(userIndex).get(\"name\").asString(),\n                                              users.get(userIndex).get(\"pat\").asString());\n            return Forge.from(\"gitlab\", hostUri, pat, null);\n        }\n\n        @Override\n        public IssueTracker createIssueHost(int userIndex) {\n            throw new RuntimeException(\"not implemented yet\");\n        }\n\n        @Override\n        public HostedRepository getHostedRepository(Forge host, String name) {\n            return host.repository(name != null ? name : config.get(\"project\").asString()).orElseThrow();\n        }\n\n        @Override\n        public IssueProject getIssueProject(IssueTracker host) {\n            return host.project(config.get(\"project\").asString());\n        }\n\n        @Override\n        public String getNamespaceName() {\n            return config.get(\"namespace\").asString();\n        }\n    }\n\n    private static class JiraCredentials implements Credentials {\n        private final JSONObject config;\n        private final TestCredentials repoCredentials;\n\n        JiraCredentials(JSONObject config) {\n            this.config = config;\n            this.repoCredentials = new TestCredentials();\n        }\n\n        @Override\n        public Forge createRepositoryHost(int userIndex) {\n            return repoCredentials.createRepositoryHost(userIndex);\n        }\n\n        @Override\n        public IssueTracker createIssueHost(int userIndex) {\n            var hostUri = URIBuilder.base(config.get(\"host\").asString()).build();\n            var users = config.get(\"users\").asArray();\n            var pat = new Credential(users.get(userIndex).get(\"name\").asString(),\n                                     users.get(userIndex).get(\"pat\").asString());\n            return IssueTracker.from(\"jira\", hostUri, pat, config);\n        }\n\n        @Override\n        public HostedRepository getHostedRepository(Forge host, String name) {\n            return repoCredentials.getHostedRepository(host, name);\n        }\n\n        @Override\n        public IssueProject getIssueProject(IssueTracker host) {\n            return host.project(config.get(\"project\").asString());\n        }\n\n        @Override\n        public String getNamespaceName() {\n            return config.get(\"namespace\").asString();\n        }\n    }\n\n    private static class TestCredentials implements Credentials {\n        private final List<TestHost> hosts = new ArrayList<>();\n        private final List<HostUser> users = List.of(\n                HostUser.create(1, \"user1\", \"User Number 1\"),\n                HostUser.create(2, \"user2\", \"User Number 2\"),\n                HostUser.create(3, \"user3\", \"User Number 3\"),\n                HostUser.create(4, \"user4\", \"User Number 4\"),\n                HostUser.create(5, \"user5\", \"User Number 5\")\n        );\n        private final JSONObject hostConf;\n\n        private TestCredentials() {\n            this(null);\n        }\n\n        private TestCredentials(JSONObject hostConf) {\n            this.hostConf = hostConf;\n        }\n\n        private TestHost createHost(int userIndex) {\n            if (userIndex == 0) {\n                hosts.add(TestHost.createNew(users, hostConf));\n            } else {\n                hosts.add(TestHost.createFromExisting(hosts.get(0), userIndex));\n            }\n            return hosts.getLast();\n        }\n\n        @Override\n        public Forge createRepositoryHost(int userIndex) {\n            return createHost(userIndex);\n        }\n\n        @Override\n        public IssueTracker createIssueHost(int userIndex) {\n            return createHost(userIndex);\n        }\n\n        @Override\n        public HostedRepository getHostedRepository(Forge host, String name) {\n            return host.repository(name != null ? name : \"test\").orElseThrow();\n        }\n\n        @Override\n        public IssueProject getIssueProject(IssueTracker host) {\n            return host.project(\"test\");\n        }\n\n        @Override\n        public String getNamespaceName() {\n            return \"test\";\n        }\n\n        @Override\n        public void close() {\n            hosts.forEach(TestHost::close);\n        }\n    }\n\n    private Credentials parseEntry(JSONObject entry, Path credentialsPath) {\n        if (!entry.contains(\"type\")) {\n            throw new RuntimeException(\"Entry type not set\");\n        }\n\n        switch (entry.get(\"type\").asString()) {\n            case \"gitlab\":\n                return new GitLabCredentials(entry);\n            case \"github\":\n                return new GitHubCredentials(entry, credentialsPath);\n            case \"jira\":\n                return new JiraCredentials(entry);\n            default:\n                throw new RuntimeException(\"Unknown entry type: \" + entry.get(\"type\").asString());\n        }\n    }\n\n    private Forge getRepositoryHost() {\n        var host = credentials.createRepositoryHost(nextHostIndex);\n        nextHostIndex++;\n        return host;\n    }\n\n    private IssueTracker getIssueHost() {\n        var host = credentials.createIssueHost(nextHostIndex);\n        nextHostIndex++;\n        return host;\n    }\n\n    public HostCredentials(TestInfo testInfo) throws IOException  {\n        this(testInfo, null);\n    }\n\n    public HostCredentials(TestInfo testInfo, JSONObject testHostConf) throws IOException  {\n        var credentialsFile = System.getProperty(\"credentials\");\n        testName = testInfo.getDisplayName();\n\n        // If no credentials have been specified, use the test host implementation\n        if (credentialsFile == null) {\n            credentials = new TestCredentials(testHostConf);\n        } else {\n            var credentialsPath = Paths.get(credentialsFile);\n            var credentialsData = Files.readAllBytes(credentialsPath);\n            var credentialsJson = JSON.parse(new String(credentialsData, StandardCharsets.UTF_8));\n            credentials = parseEntry(credentialsJson.asObject(), credentialsPath.getParent());\n        }\n    }\n\n    private boolean getLock(HostedRepository repo) throws IOException {\n        try (var tempFolder = new TemporaryDirectory()) {\n            var repoFolder = tempFolder.path().resolve(\"lock\");\n            var lockFile = repoFolder.resolve(\"lock.txt\");\n            Repository localRepo;\n            try {\n                localRepo = Repository.materialize(repoFolder, repo.authenticatedUrl(), \"testlock\");\n            } catch (IOException e) {\n                // If the branch does not exist, we'll try to create it\n                localRepo = TestableRepository.init(repoFolder, VCS.GIT);\n            }\n\n            if (Files.exists(lockFile)) {\n                var currentLock = Files.readString(lockFile).strip();\n                var lockTime = ZonedDateTime.parse(currentLock, DateTimeFormatter.ISO_DATE_TIME);\n                if (lockTime.isBefore(ZonedDateTime.now().minus(Duration.ofMinutes(10)))) {\n                    log.info(\"Stale lock encountered - overwriting it\");\n                } else {\n                    log.info(\"Active lock encountered - waiting\");\n                    return false;\n                }\n            }\n\n            // The lock either doesn't exist or is stale, try to grab it\n            var lockHash = commitLock(localRepo);\n            localRepo.push(lockHash, repo.authenticatedUrl(), \"testlock\");\n            log.info(\"Obtained credentials lock\");\n\n            // If no exception occurs (such as the push fails), we have obtained the lock\n            return true;\n        }\n    }\n\n    private void releaseLock(HostedRepository repo) throws IOException {\n        try (var tempFolder = new TemporaryDirectory()) {\n            var repoFolder = tempFolder.path().resolve(\"lock\");\n            var lockFile = repoFolder.resolve(\"lock.txt\");\n            Repository localRepo;\n            localRepo = Repository.materialize(repoFolder, repo.authenticatedUrl(), \"testlock\");\n            localRepo.remove(lockFile);\n            var lockHash = localRepo.commit(\"Unlock\", \"test\", \"test@test.test\");\n            localRepo.push(lockHash, repo.authenticatedUrl(), \"testlock\");\n        }\n    }\n\n    public Hash commitLock(Repository localRepo) throws IOException {\n        var lockFile = localRepo.root().resolve(\"lock.txt\");\n        Files.writeString(lockFile, ZonedDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME));\n        localRepo.add(lockFile);\n        var lockHash = localRepo.commit(\"Lock\", \"test\", \"test@test.test\");\n        localRepo.branch(lockHash, \"testlock\");\n        return lockHash;\n    }\n\n    public HostedRepository getHostedRepository() throws IOException {\n        return getHostedRepository(null);\n    }\n\n    /**\n     * Get a hosted repository with a specific name. Unless a unique name is\n     * specified, the underlying git repository will be the same for each\n     * HostedRepository.\n     */\n    public HostedRepository getHostedRepository(String name) throws IOException {\n        var host = getRepositoryHost();\n        var repo = (TestHostedRepository) credentials.getHostedRepository(host, name);\n\n        if (repo.localRepository() != null) {\n            var retryCount = 0;\n            while (credentialsLock == null) {\n                try {\n                    if (getLock(repo)) {\n                        credentialsLock = repo;\n                    }\n                } catch (IOException e) {\n                    if (retryCount > 3) {\n                        throw e;\n                    }\n                    try {\n                        Thread.sleep(Duration.ofSeconds(1));\n                        retryCount++;\n                    } catch (InterruptedException ignored) {\n                    }\n                }\n            }\n        }\n        return repo;\n    }\n\n    public IssueProject getIssueProject() {\n        var host = getIssueHost();\n        return credentials.getIssueProject(host);\n    }\n\n    public TestPullRequest createPullRequest(HostedRepository sourceRepository, HostedRepository targetRepository,\n                                         String targetRef, String sourceRef, String title, List<String> body, boolean draft) {\n        var pr = (TestPullRequest) sourceRepository.createPullRequest(targetRepository, targetRef, sourceRef, title, body, draft);\n        pullRequestsToBeClosed.add(pr);\n        return pr;\n    }\n\n    public TestPullRequest createPullRequest(HostedRepository targetRepository, String targetRef, String sourceRef, String title, List<String> body, boolean draft) {\n        return createPullRequest(targetRepository, targetRepository, targetRef, sourceRef, title, body, draft);\n    }\n\n        public TestPullRequest createPullRequest(HostedRepository targetRepository, String targetRef, String sourceRef, String title, boolean draft) {\n        return createPullRequest(targetRepository, targetRepository, targetRef, sourceRef, title, List.of(\"PR body\"), draft);\n    }\n\n    public TestPullRequest createPullRequest(HostedRepository targetRepository, String targetRef, String sourceRef, String title, List<String> body) {\n        return createPullRequest(targetRepository, targetRepository, targetRef, sourceRef, title, body, false);\n    }\n\n    public TestPullRequest createPullRequest(HostedRepository targetRepository, String targetRef, String sourceRef, String title) {\n        return createPullRequest(targetRepository, targetRepository, targetRef, sourceRef, title, List.of(\"PR body\"), false);\n    }\n\n    public TestPullRequest createPullRequest(HostedRepository sourceRepository, HostedRepository targetRepository, String targetRef, String sourceRef, String title) {\n        return createPullRequest(sourceRepository, targetRepository, targetRef, sourceRef, title, List.of(\"PR body\"), false);\n    }\n\n    public TestIssueTrackerIssue createIssue(IssueProject issueProject, String title) {\n        var issue = (TestIssueTrackerIssue) issueProject.createIssue(title, List.of(), Map.of(\"issuetype\", JSON.of(\"Bug\")));\n        issuesToBeClosed.add(issue);\n        return issue;\n    }\n\n    public CensusBuilder getCensusBuilder() {\n        return CensusBuilder.create(credentials.getNamespaceName());\n    }\n\n    @Override\n    public void close() {\n        for (var pr : pullRequestsToBeClosed) {\n            pr.setState(PullRequest.State.CLOSED);\n        }\n        for (var issue : issuesToBeClosed) {\n            issue.setState(Issue.State.CLOSED);\n        }\n        if (credentialsLock != null) {\n            try {\n                releaseLock(credentialsLock);\n                log.info(\"Released credentials lock for \" + testName);\n            } catch (IOException e) {\n                throw new UncheckedIOException(e);\n            }\n            credentialsLock = null;\n        }\n\n        credentials.close();\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/SMTPServer.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport org.openjdk.skara.email.*;\n\nimport java.io.*;\nimport java.net.*;\nimport java.time.*;\nimport java.util.ArrayList;\nimport java.util.concurrent.ConcurrentLinkedDeque;\nimport java.util.logging.Logger;\nimport java.util.regex.Pattern;\n\npublic class SMTPServer implements AutoCloseable {\n    private final ServerSocket serverSocket;\n    private final ConcurrentLinkedDeque<Email> emails = new ConcurrentLinkedDeque<>();\n\n    private final static Logger log = Logger.getLogger(\"org.openjdk.skara.test\");;\n    private final static Pattern commandPattern = Pattern.compile(\"^([A-Z]+) ?(.*)$\");\n    private final static Pattern encodeQuotedPrintablePattern = Pattern.compile(\"([^\\\\x00-\\\\x7f]+)\");\n    private final static Pattern headerPattern = Pattern.compile(\"[^A-Za-z0-9-]+: .+\");\n\n    private class AcceptThread implements Runnable {\n        private void sendLine(String line, BufferedWriter out) throws IOException {\n            log.fine(\"> \" + line);\n            out.write(line + \"\\n\");\n            out.flush();\n        }\n\n        private String readLine(BufferedReader in) throws IOException {\n            while (!in.ready()) {\n                try {\n                    Thread.sleep(10);\n                } catch (InterruptedException ignored) {\n                }\n            }\n            var line = in.readLine();\n            log.fine(\"< \" + line);\n            return line;\n        }\n\n        private void handleSession(BufferedReader in, BufferedWriter out) throws IOException {\n            sendLine(\"220 localhost SMTP\", out);\n            var message = new ArrayList<String>();\n            var done = false;\n            while (!done) {\n                var line = readLine(in);\n                var commandMatcher = commandPattern.matcher(line);\n                if (!commandMatcher.matches()) {\n                    throw new RuntimeException(\"Illegal input: \" + line);\n                }\n                switch (commandMatcher.group(1)) {\n                    case \"EHLO\":\n                        sendLine(\"250 HELP\", out);\n                        break;\n                    case \"MAIL\":\n                        sendLine(\"250 FROM OK\", out);\n                        break;\n                    case \"RCPT\":\n                        sendLine(\"250 RCPT OK\", out);\n                        break;\n                    case \"DATA\":\n                        sendLine(\"354 Enter message now, end with .\", out);\n                        while (true) {\n                            var messageLine = readLine(in);\n                            if (messageLine.equals(\".\")) {\n                                sendLine(\"250 MESSAGE OK\", out);\n                                break;\n                            }\n                            message.add(messageLine);\n                        }\n                        break;\n                    case \"QUIT\":\n                        sendLine(\"BYE\", out);\n                        done = true;\n                        break;\n                }\n            }\n\n            // Email headers are only 7-bit safe, ensure that we break any high ascii passing through\n            var inHeader = true;\n            var mailBody = new StringBuilder();\n            for (var line : message) {\n                if (inHeader) {\n                    var headerMatcher = headerPattern.matcher(line);\n                    if (headerMatcher.matches()) {\n                        var quoteMatcher = encodeQuotedPrintablePattern.matcher(String.join(\"\\n\", message));\n                        var ascii7line = quoteMatcher.replaceAll(mo -> \"HIGH_ASCII\");\n                        mailBody.append(ascii7line);\n                        mailBody.append(\"\\n\");\n                        continue;\n                    } else {\n                        inHeader = false;\n                    }\n                }\n                if (line.startsWith(\".\")) {\n                    line = line.substring(1);\n                }\n                mailBody.append(line);\n                mailBody.append(\"\\n\");\n            }\n\n            var email = Email.parse(mailBody.toString());\n            emails.addLast(email);\n        }\n\n        @Override\n        public void run() {\n            while (!serverSocket.isClosed()) {\n                try {\n                    try (var socket = serverSocket.accept();\n                         var input = new InputStreamReader(socket.getInputStream());\n                         var output = new OutputStreamWriter(socket.getOutputStream())) {\n                        handleSession(new BufferedReader(input), new BufferedWriter(output));\n                    }\n                } catch (SocketException e) {\n                    // Socket closed\n                } catch (IOException e) {\n                    throw new UncheckedIOException(e);\n                }\n            }\n        }\n    }\n\n    public SMTPServer() throws IOException {\n        serverSocket = new ServerSocket(0);\n        var acceptThread = new Thread(new AcceptThread());\n        acceptThread.start();\n    }\n\n    public String address() {\n        return InetAddress.getLoopbackAddress().getHostAddress() + \":\" + serverSocket.getLocalPort();\n    }\n\n    public Email receive(Duration timeout) {\n        var start = Instant.now();\n        while (emails.isEmpty() && start.plus(timeout).isAfter(Instant.now())) {\n            try {\n                Thread.sleep(10);\n            } catch (InterruptedException ignored) {\n            }\n        }\n\n        if (emails.isEmpty()) {\n            throw new RuntimeException(\"No email received\");\n        }\n        return emails.removeFirst();\n    }\n\n    @Override\n    public void close() throws IOException {\n        serverSocket.close();\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TemporaryDirectory.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\n\npublic class TemporaryDirectory implements AutoCloseable {\n    private final Path p;\n    private final boolean shouldRemove;\n\n    public TemporaryDirectory() {\n        this(true);\n    }\n\n    public TemporaryDirectory(boolean shouldRemove) {\n        try {\n            p = Files.createTempDirectory(\"RepositoryTests\").toRealPath();\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n        this.shouldRemove = shouldRemove;\n    }\n\n    public Path path() {\n        return p;\n    }\n\n    @Override\n    public void close() {\n        if (shouldRemove) {\n            try (var paths = Files.walk(p)) {\n                paths.map(Path::toFile)\n                     .sorted(Comparator.reverseOrder())\n                     .forEach(File::delete);\n            } catch (IOException io) {\n                throw new RuntimeException(io);\n            }\n        } else {\n            System.out.println(\"TemporaryDirectory: \" + p.toString());\n        }\n    }\n\n    @Override\n    public String toString() {\n        return p.toString();\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        TemporaryDirectory that = (TemporaryDirectory) o;\n        return Objects.equals(p, that.p);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(p);\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TestBotFactory.java",
    "content": "/*\n * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport org.openjdk.skara.bot.*;\nimport org.openjdk.skara.ci.ContinuousIntegration;\nimport org.openjdk.skara.forge.HostedRepository;\nimport org.openjdk.skara.issuetracker.IssueProject;\nimport org.openjdk.skara.issuetracker.IssueTracker;\nimport org.openjdk.skara.json.*;\n\nimport java.nio.file.Path;\nimport java.util.*;\n\npublic class TestBotFactory {\n    private final Map<String, HostedRepository> hostedRepositories;\n    private final Map<String, IssueTracker> issueTrackers;\n    private final Map<String, IssueProject> issueProjects;\n    private final Path storagePath;\n    private final JSONObject defaultConfiguration;\n\n    private TestBotFactory(Map<String, HostedRepository> hostedRepositories,\n            Map<String, IssueTracker> issueTrackers, Map<String, IssueProject> issueProjects,\n            Path storagePath, JSONObject defaultConfiguration) {\n        this.hostedRepositories = Collections.unmodifiableMap(hostedRepositories);\n        this.issueTrackers = Collections.unmodifiableMap(issueTrackers);\n        this.issueProjects = Collections.unmodifiableMap(issueProjects);\n        this.storagePath = storagePath;\n        this.defaultConfiguration = defaultConfiguration;\n    }\n\n    public static TestBotFactoryBuilder newBuilder() {\n        return new TestBotFactoryBuilder();\n    }\n\n    public static class TestBotFactoryBuilder {\n        private final Map<String, HostedRepository> hostedRepositories = new HashMap<>();\n        private final Map<String, IssueTracker> issueTrackers = new HashMap<>();\n        private final Map<String, IssueProject> issueProjects = new HashMap<>();\n        private final JSONObject defaultConfiguration = JSON.object();\n        private Path storagePath;\n\n        private TestBotFactoryBuilder() {\n        }\n\n        public TestBotFactoryBuilder addHostedRepository(String name, HostedRepository hostedRepository) {\n            hostedRepositories.put(name, hostedRepository);\n            return this;\n        }\n\n        public TestBotFactoryBuilder addIssueTracker(String name, IssueTracker issueTracker) {\n            issueTrackers.put(name, issueTracker);\n            return this;\n        }\n\n        public TestBotFactoryBuilder addIssueProject(String name, IssueProject issueProject) {\n            issueProjects.put(name, issueProject);\n            return this;\n        }\n\n        public TestBotFactoryBuilder addConfiguration(String field, JSONValue value) {\n            defaultConfiguration.put(field, value);\n            return this;\n        }\n\n        public TestBotFactoryBuilder storagePath(Path storagePath) {\n            this.storagePath = storagePath;\n            return this;\n        }\n\n        public TestBotFactory build() {\n            return new TestBotFactory(hostedRepositories, issueTrackers, issueProjects, storagePath, defaultConfiguration);\n        }\n    }\n\n    public Bot create(String name, JSONObject configuration) {\n        var bots = createBots(name, configuration);\n        if (bots.size() != 1) {\n            throw new RuntimeException(\"Factory did not create a bot instance\");\n        }\n        return bots.get(0);\n    }\n\n    public List<Bot> createBots(String name, JSONObject configuration) {\n        var finalConfiguration = JSON.object();\n        for (var defaultField : defaultConfiguration.fields()) {\n            finalConfiguration.put(defaultField.name(), defaultField.value());\n        }\n        for (var field : configuration.fields()) {\n            finalConfiguration.put(field.name(), field.value());\n        }\n\n        var botConfiguration = new BotConfiguration() {\n            @Override\n            public Path storageFolder() {\n                return storagePath;\n            }\n\n            @Override\n            public HostedRepository repository(String name) {\n                var repoName = name.split(\":\")[0];\n                if (!hostedRepositories.containsKey(repoName) && !hostedRepositories.containsKey(name)) {\n                    throw new RuntimeException(\"Unknown repository: \" + repoName + \" or \" + name);\n                }\n                if(hostedRepositories.get(repoName) !=null){\n                    return hostedRepositories.get(repoName);\n                }\n                return hostedRepositories.get(name);\n            }\n\n            @Override\n            public IssueTracker issueTracker(String name) {\n                if (!issueTrackers.containsKey(name)) {\n                    throw new RuntimeException(\"Unknown issue tracker: \" + name);\n                }\n                return issueTrackers.get(name);\n            }\n\n            @Override\n            public IssueProject issueProject(String name) {\n                if (!issueProjects.containsKey(name)) {\n                    throw new RuntimeException(\"Unknown issue project: \" + name);\n                }\n                return issueProjects.get(name);\n            }\n\n            @Override\n            public ContinuousIntegration continuousIntegration(String name) {\n                return null;\n            }\n\n            @Override\n            public String repositoryRef(String name) {\n                return name.split(\":\")[1];\n            }\n\n            @Override\n            public String repositoryName(String name) {\n                var refIndex = name.indexOf(':');\n                if (refIndex >= 0) {\n                    name = name.substring(0, refIndex);\n                }\n                var orgIndex = name.lastIndexOf('/');\n                if (orgIndex >= 0) {\n                    name = name.substring(orgIndex + 1);\n                }\n                return name;\n            }\n\n            @Override\n            public JSONObject specific() {\n                return finalConfiguration;\n            }\n        };\n\n        var factories = BotFactory.getBotFactories();\n        for (var factory : factories) {\n            if (factory.name().equals(name)) {\n                return factory.create(botConfiguration);\n            }\n        }\n        throw new RuntimeException(\"Failed to find bot factory with name: \" + name);\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TestBotRunner.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport org.openjdk.skara.bot.*;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.*;\n\npublic class TestBotRunner {\n    @FunctionalInterface\n    public interface AfterItemHook {\n        void run(WorkItem item);\n    }\n\n    public static void runPeriodicItems(Bot bot) throws IOException {\n        runPeriodicItems(bot, item -> {});\n    }\n\n    public static void runPeriodicItems(Bot bot, AfterItemHook afterItemHook) throws IOException {\n        try (var scratchFolder = new TemporaryDirectory()) {\n            runPeriodicItems(bot, afterItemHook, scratchFolder.path());\n        }\n    }\n\n    public static void runPeriodicItems(Bot bot, Path scratchFolder) throws IOException {\n        runPeriodicItems(bot, item -> {}, scratchFolder);\n    }\n\n    public static void runPeriodicItems(Bot bot, AfterItemHook afterItemHook, Path scratchFolder) throws IOException {\n        var items = new LinkedList<>(bot.getPeriodicItems());\n        for (var item = items.pollFirst(); item != null; item = items.pollFirst()) {\n            Collection<WorkItem> followUpItems;\n            try {\n                followUpItems = item.run(scratchFolder);\n                afterItemHook.run(item);\n            } catch (RuntimeException e) {\n                item.handleRuntimeException(e);\n                // Allow tests to assert on these as well\n                throw e;\n            }\n            if (followUpItems != null) {\n                items.addAll(followUpItems);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TestHost.java",
    "content": "/*\n * Copyright (c) 2019, 2026, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport java.time.Duration;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.*;\nimport org.openjdk.skara.network.RestRequest;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.net.URISyntaxException;\nimport java.nio.file.Files;\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\nimport static org.openjdk.skara.issuetracker.jira.JiraProject.JEP_NUMBER;\nimport static org.openjdk.skara.issuetracker.jira.JiraProject.RESOLVED_IN_BUILD;\n\npublic class TestHost implements Forge, IssueTracker {\n    /***\n     * TestBackportEndpoint simulates the JBS custom endpoint for creating backports in JBS\n     */\n    private static class TestBackportEndpoint implements IssueTracker.CustomEndpoint, IssueTracker.CustomEndpointRequest {\n        private final TestHost host;\n\n        private JSONValue body;\n\n        private TestBackportEndpoint(TestHost host) {\n            this.host = host;\n        }\n\n        @Override\n        public IssueTracker.CustomEndpointRequest post() {\n            return this;\n        }\n\n        @Override\n        public IssueTracker.CustomEndpointRequest body(JSONValue body) {\n            this.body = body;\n            return this;\n        }\n\n        @Override\n        public IssueTracker.CustomEndpointRequest header(String value, String name) {\n            // Not needed\n            return this;\n        }\n\n        @Override\n        public IssueTracker.CustomEndpointRequest onError(RestRequest.ErrorTransform transform) {\n            // Not needed\n            return this;\n        }\n\n        @Override\n        public JSONValue execute() {\n            if (body == null) {\n                throw new IllegalStateException(\"Must set body\");\n            }\n\n            // A TestHost can only handle a single project and since a backport\n            // requires a primary issue to exist, then there must already exist\n            // a project for the primary issue\n            var project = host.data.issueProjects.entrySet().stream().findFirst().orElseThrow().getValue();\n            var primary = project.issue(body.get(\"parentIssueKey\").asString()).orElseThrow();\n\n            var props = new HashMap<String, JSONValue>();\n            props.put(\"issuetype\", JSON.of(\"Backport\"));\n            // Propagate properties set in POST request body\n            if (body.contains(\"level\")) {\n                props.put(\"security\", body.get(\"level\"));\n            }\n            if (body.contains(\"fixVersion\")) {\n                props.put(\"fixVersions\", JSON.array().add(body.get(\"fixVersion\")));\n            }\n\n            // Propagate properties from the primary issue *except* those\n            // that can be set via the POST request body. The custom\n            // RESOLVED_IN_BUILD property should also not propagate\n            var ignore = Set.of(\"issuetype\", \"assignee\", \"security\", \"fixVersions\", RESOLVED_IN_BUILD);\n            for (var entry : primary.properties().entrySet()) {\n                if (!ignore.contains(entry.getKey())) {\n                    props.put(entry.getKey(), entry.getValue());\n                }\n            }\n\n            var backport = project.createIssue(primary.title(), Arrays.asList(primary.body().split(\"\\n\")), props);\n            if (body.contains(\"assignee\")) {\n                var user = host.user(body.get(\"assignee\").asString()).orElseThrow();\n                backport.setAssignees(List.of(user));\n            }\n            backport.addLink(Link.create(primary, \"backport of\").build());\n            primary.addLink(Link.create(backport, \"backported by\").build());\n            return JSON.object().put(\"key\", backport.id());\n        }\n    }\n\n    /**\n     * If test needs to name a repository that should not exist on the TestHost,\n     * use this as the name of the repository.\n     */\n    public static final String NON_EXISTING_REPO = \"non-existing-repo\";\n    /**\n     * For tests that do not actually need a local repository on disk, use\n     * this as the name.\n     */\n    public static final String FAKE_REPO = \"fake-repo\";\n\n    private final int currentUser;\n    private HostData data;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.test\");\n    // Setting this field doesn't change the behavior of the TestHost, but it changes\n    // what the associated method returns, which triggers different code paths in\n    // dependent code for testing.\n    private Duration minTimeStampUpdateInterval = Duration.ZERO;\n    // Setting this field doesn't change the behavior of the TestHost, but it changes\n    // what the associated method returns, which triggers different code paths in\n    // dependent test code.\n    private Duration timeStampQueryPrecision = Duration.ofNanos(1);\n\n    private static class HostData {\n        final List<HostUser> users = new ArrayList<>();\n        final Map<String, Repository> repositories = new HashMap<>();\n        final Map<String, IssueProject> issueProjects = new HashMap<>();\n        final Set<TemporaryDirectory> folders = new HashSet<>();\n        private final Map<String, TestPullRequestStore> pullRequests = new HashMap<>();\n        private final Map<String, TestIssueTrackerIssueStore> issues = new HashMap<>();\n        private String pullRequestTemplate = null;\n    }\n\n    // Map of org to map of user to MemberState\n    private final Map<String, Map<String, MemberState>> organizationMembers = new HashMap<>();\n\n    private Repository createLocalRepository() {\n        var folder = new TemporaryDirectory();\n        data.folders.add(folder);\n        try {\n            var repo = TestableRepository.init(folder.path().resolve(\"hosted.git\"), VCS.GIT);\n            Files.writeString(repo.root().resolve(\"content.txt\"), \"Initial content\");\n            repo.add(repo.root().resolve(\"content.txt\"));\n            var hash = repo.commit(\"Initial content\", \"author\", \"author@none\");\n            repo.push(hash, repo.root().toUri(), \"testhostcontent\");\n            repo.checkout(hash, true);\n            return repo;\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    public static TestHost createNew(List<HostUser> users) {\n        return createNew(users, null);\n    }\n\n    public static TestHost createNew(List<HostUser> users, JSONObject conf) {\n        var data = new HostData();\n        data.users.addAll(users);\n        if (conf != null) {\n            if (conf.contains(\"prTemplate\")) {\n                data.pullRequestTemplate = conf.get(\"prTemplate\")\n                    .asArray()\n                    .stream()\n                    .map(JSONValue::asString)\n                    .collect(Collectors.joining(\"\\n\"));\n            }\n        }\n        var host = new TestHost(data, 0);\n        return host;\n    }\n\n    static TestHost createFromExisting(TestHost existing, int userIndex) {\n        var host = new TestHost(existing.data, userIndex);\n        return host;\n    }\n\n    private TestHost(HostData data, int currentUser) {\n        this.data = data;\n        this.currentUser = currentUser;\n    }\n\n    @Override\n    public Optional<IssueTracker.CustomEndpoint> lookupCustomEndpoint(String path) {\n        switch (path) {\n            case \"/rest/jbs/1.0/backport/\":\n                return Optional.of(new TestBackportEndpoint(this));\n            default:\n                return Optional.empty();\n        }\n    }\n\n    @Override\n    public boolean isValid() {\n        return true;\n    }\n\n    @Override\n    public String name() {\n        return \"Test\";\n    }\n\n    @Override\n    public String hostname() {\n        return \"test.test\";\n    }\n\n    @Override\n    public URI uri() {\n        try {\n            return new URI(hostname());\n        } catch (URISyntaxException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    @Override\n    public Optional<String> defaultPullRequestTemplate() {\n        return Optional.ofNullable(data.pullRequestTemplate);\n    }\n\n    @Override\n    public Optional<HostedRepository> repository(String name) {\n        Repository localRepository;\n        if (NON_EXISTING_REPO.equals(name)) {\n            return Optional.empty();\n        }\n        if (FAKE_REPO.equals(name)) {\n            localRepository = null;\n        } else if (data.repositories.containsKey(name)) {\n            localRepository = data.repositories.get(name);\n        } else {\n            localRepository = createLocalRepository();\n            data.repositories.put(name, localRepository);\n        }\n        return Optional.of(new TestHostedRepository(this, name, localRepository));\n    }\n\n    @Override\n    public IssueProject project(String name) {\n        if (data.issueProjects.containsKey(name)) {\n            return data.issueProjects.get(name);\n        } else {\n            if (data.issueProjects.size() > 0) {\n                throw new RuntimeException(\"A test host can only manage a single issue project\");\n            }\n            var issueProject = new TestIssueProject(this, name);\n            data.issueProjects.put(name, issueProject);\n            return issueProject;\n        }\n    }\n\n    @Override\n    public Optional<HostUser> user(String username) {\n        return data.users.stream()\n                         .filter(user -> user.username().equals(username))\n                         .findAny();\n    }\n\n    @Override\n    public Optional<HostUser> userById(String id) {\n        return data.users.stream()\n                .filter(user -> user.id().equals(id))\n                .findAny();\n    }\n\n    @Override\n    public HostUser currentUser() {\n        return data.users.get(currentUser);\n    }\n\n    @Override\n    public boolean isMemberOf(String groupId, HostUser user) {\n        return false;\n    }\n\n    @Override\n    public Optional<String> search(Hash hash, boolean includeDiffs) {\n        for (var key : data.repositories.keySet()) {\n            var repo = repository(key).orElseThrow();\n            var commit = repo.commit(hash, includeDiffs);\n            if (commit.isPresent()) {\n                return Optional.of(repo.name());\n            }\n        }\n        return Optional.empty();\n    }\n\n    void close() {\n        if (currentUser == 0) {\n            data.folders.forEach(TemporaryDirectory::close);\n        }\n    }\n\n    TestPullRequest createPullRequest(TestHostedRepository targetRepository, TestHostedRepository sourceRepository,\n            String targetRef, String sourceRef, String title, List<String> body, boolean draft) {\n        var id = String.valueOf(data.pullRequests.size() + 1);\n        var prStore = new TestPullRequestStore(id, targetRepository.forge().currentUser(), title, body,\n                sourceRepository, targetRef, sourceRef, draft);\n        data.pullRequests.put(id, prStore);\n        return new TestPullRequest(prStore, targetRepository);\n    }\n\n    TestPullRequest getPullRequest(TestHostedRepository repository, String id) {\n        var store = data.pullRequests.get(id);\n        return new TestPullRequest(store, repository);\n    }\n\n    List<TestPullRequest> getPullRequests(TestHostedRepository repository) {\n        return data.pullRequests.entrySet().stream()\n                                .sorted(Comparator.comparing(Map.Entry::getKey))\n                                .map(pr -> getPullRequest(repository, pr.getKey()))\n                                .collect(Collectors.toList());\n    }\n\n    TestIssueTrackerIssue createIssue(TestIssueProject issueProject, String title, List<String> body, Map<String, JSONValue> properties) {\n        var id = issueProject.projectName().toUpperCase() + \"-\" + (data.issues.size() + 1);\n        HostUser author = issueProject.issueTracker().currentUser();\n        var issueStore = new TestIssueTrackerIssueStore(id, issueProject, author, title, body, properties);\n        data.issues.put(id, issueStore);\n        return new TestIssueTrackerIssue(issueStore, author);\n    }\n\n    TestIssueTrackerIssue getIssue(TestIssueProject issueProject, String id) {\n        var issueStore = data.issues.get(id);\n        if (issueStore == null) {\n            return null;\n        }\n        return new TestIssueTrackerIssue(issueStore, issueProject.issueTracker().currentUser());\n    }\n\n    TestIssueTrackerIssue getJepIssue(TestIssueProject issueProject, String jepId) {\n        var jepIssue = data.issues.entrySet().stream()\n                .sorted(Map.Entry.comparingByKey())\n                .map(issue -> getIssue(issueProject, issue.getKey()))\n                .filter(issue -> {\n                    var issueType = issue.properties().get(\"issuetype\");\n                    var jepNumber = issue.properties().get(JEP_NUMBER);\n                    return issueType != null && \"JEP\".equals(issueType.asString()) &&\n                           jepNumber != null && jepId.equals(jepNumber.asString());\n                })\n                .findFirst();\n        return jepIssue.orElse(null);\n    }\n\n    List<TestIssueTrackerIssue> getIssues(TestIssueProject issueProject) {\n        return data.issues.entrySet().stream()\n                          .sorted(Comparator.comparing(Map.Entry::getKey))\n                          .map(issue -> getIssue(issueProject, issue.getKey()))\n                          .filter(i -> i.state().equals(Issue.State.OPEN))\n                          .collect(Collectors.toList());\n    }\n\n    List<TestIssueTrackerIssue> getIssues(TestIssueProject issueProject, ZonedDateTime updatedAfter) {\n        return data.issues.entrySet().stream()\n                          .sorted(Map.Entry.comparingByKey())\n                          .map(issue -> getIssue(issueProject, issue.getKey()))\n                          .filter(i -> !i.updatedAt().isBefore(updatedAfter))\n                          .collect(Collectors.toList());\n    }\n\n    List<TestIssueTrackerIssue> getCsrIssues(TestIssueProject issueProject, ZonedDateTime updatedAfter) {\n        return data.issues.entrySet().stream()\n                .sorted(Map.Entry.comparingByKey())\n                .map(issue -> getIssue(issueProject, issue.getKey()))\n                .filter(i -> {\n                    var type = i.properties().get(\"issuetype\");\n                    return type != null && \"CSR\".equals(type.asString());\n                })\n                .filter(i -> !i.updatedAt().isBefore(updatedAfter))\n                .collect(Collectors.toList());\n    }\n\n    Optional<TestIssueTrackerIssue> getLastUpdatedIssue(TestIssueProject issueProject) {\n        return data.issues.keySet().stream()\n                .map(testIssue -> getIssue(issueProject, testIssue))\n                .max(Comparator.comparing(TestIssueTrackerIssue::updatedAt));\n    }\n\n    public void setMinTimeStampUpdateInterval(Duration minTimeStampUpdateInterval) {\n        this.minTimeStampUpdateInterval = minTimeStampUpdateInterval;\n    }\n\n    @Override\n    public Duration minTimeStampUpdateInterval() {\n        return minTimeStampUpdateInterval;\n    }\n\n    public void setTimeStampQueryPrecision(Duration timeStampQueryPrecision) {\n        this.timeStampQueryPrecision = timeStampQueryPrecision;\n    }\n\n    @Override\n    public Duration timeStampQueryPrecision() {\n        return timeStampQueryPrecision;\n    }\n\n    @Override\n    public List<HostUser> groupMembers(String group) {\n        return organizationMembers.getOrDefault(group, Map.of()).keySet().stream()\n                .map(u -> user(u).orElseThrow())\n                .toList();\n    }\n\n    @Override\n    public void addGroupMember(String group, HostUser user) {\n        organizationMembers.putIfAbsent(group, new HashMap<>());\n        organizationMembers.get(group).put(user.username(), MemberState.PENDING);\n    }\n\n    /**\n     * Test method to update an existing org member to active status\n     */\n    public void confirmGroupMember(String group, String user) {\n        organizationMembers.get(group).put(user, MemberState.ACTIVE);\n    }\n\n    @Override\n    public MemberState groupMemberState(String group, HostUser user) {\n        return organizationMembers.getOrDefault(group, Map.of()).getOrDefault(user.username(), MemberState.MISSING);\n    }\n\n    /**\n     * Test method to update the active state of an existing user\n     */\n    public void setUserActive(String user, boolean active) {\n        var currentUser = user(user).orElseThrow();\n        data.users.remove(currentUser);\n        var newUser = HostUser.create(currentUser.id(), currentUser.username(), currentUser.fullName(), active);\n        data.users.add(newUser);\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TestHostedRepository.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.*;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.issuetracker.Label;\nimport org.openjdk.skara.json.JSONValue;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.net.*;\nimport java.time.Duration;\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class TestHostedRepository extends TestIssueProject implements HostedRepository {\n    private final TestHost host;\n    private final String projectName;\n    private final Repository localRepository;\n    private final Pattern pullRequestPattern;\n    private final Map<Hash, List<CommitComment>> commitComments;\n    private final List<Collaborator> collaborators = new ArrayList<>();\n    private final List<Label> labels = new ArrayList<>();\n    private final Set<Check> checks = new HashSet<>();\n    private final Set<String> protectedBranchPatterns = new HashSet<>();\n    private final Map<String, ZonedDateTime> deployKeys = new HashMap<>();\n    private String namespace = \"test\";\n\n    public TestHostedRepository(TestHost host, String projectName, Repository localRepository) {\n        super(host, projectName);\n        this.host = host;\n        this.projectName = projectName;\n        this.localRepository = localRepository;\n        pullRequestPattern = Pattern.compile(webUrl().toString() + \"/pr/\" + \"(\\\\d+)\");\n        commitComments = new HashMap<>();\n    }\n\n    /**\n     * Creates an instance without a backing local repository that will not support any actual repository interaction\n     */\n    public TestHostedRepository(String projectName) {\n        super(null, projectName);\n        this.host = null;\n        this.projectName = projectName;\n        this.localRepository = null;\n        pullRequestPattern = null;\n        commitComments = new HashMap<>();\n    }\n\n    /**\n     * Creates an instance without a backing local repository that will not support any actual repository interaction\n     */\n    public TestHostedRepository(TestHost host, String projectName) {\n        super(host, projectName);\n        this.host = host;\n        this.projectName = projectName;\n        this.localRepository = null;\n        pullRequestPattern = null;\n        commitComments = new HashMap<>();\n    }\n\n    @Override\n    public Forge forge() {\n        return host;\n    }\n\n    @Override\n    public Optional<HostedRepository> parent() {\n        throw new RuntimeException(\"Not implemented yet\");\n    }\n\n    @Override\n    public PullRequest createPullRequest(HostedRepository target, String targetRef, String sourceRef, String title, List<String> body, boolean draft) {\n        return host.createPullRequest((TestHostedRepository) target, this, targetRef, sourceRef, title, body, draft);\n    }\n\n    @Override\n    public PullRequest pullRequest(String id) {\n        return host.getPullRequest(this, id);\n    }\n\n    @Override\n    public List<PullRequest> pullRequests() {\n        return new ArrayList<>(host.getPullRequests(this));\n    }\n\n    @Override\n    public List<PullRequest> openPullRequests() {\n        return host.getPullRequests(this).stream()\n                   .filter(pr -> pr.state().equals(Issue.State.OPEN))\n                   .collect(Collectors.toList());\n    }\n\n    @Override\n    public List<PullRequest> pullRequestsAfter(ZonedDateTime updatedAfter) {\n        return host.getPullRequests(this).stream()\n                   .filter(pr -> !pr.updatedAt().isBefore(updatedAfter))\n                   .sorted(Comparator.comparing(PullRequest::updatedAt).reversed())\n                   .collect(Collectors.toList());\n    }\n\n    @Override\n    public List<PullRequest> openPullRequestsAfter(ZonedDateTime updatedAfter) {\n        return host.getPullRequests(this).stream()\n                .filter(pr -> pr.state().equals(Issue.State.OPEN))\n                .filter(pr -> !pr.updatedAt().isBefore(updatedAfter))\n                .collect(Collectors.toList());\n    }\n\n    @Override\n    public List<PullRequest> findPullRequestsWithComment(String author, String body) {\n        return openPullRequests().stream()\n                             .filter(pr -> pr.comments().stream()\n                                                .filter(comment -> author == null || comment.author().username().equals(author))\n                                                .filter(comment -> comment == null ||comment.body().contains(body))\n                                                .count() > 0\n                                )\n                             .collect(Collectors.toList());\n    }\n\n    @Override\n    public Optional<PullRequest> parsePullRequestUrl(String url) {\n        var matcher = pullRequestPattern.matcher(url);\n        if (matcher.find()) {\n            return Optional.of(pullRequest(matcher.group(1)));\n        } else {\n            return Optional.empty();\n        }\n    }\n\n    @Override\n    public String name() {\n        return projectName;\n    }\n\n    @Override\n    public String group() {\n        if (projectName.contains(\"/\")) {\n            return projectName.split(\"/\")[0];\n        } else {\n            return \"\";\n        }\n    }\n\n    @Override\n    public URI authenticatedUrl() {\n        try {\n            // We need a URL without a trailing slash\n            if (localRepository != null) {\n                var fileName = localRepository.root().getFileName().toString();\n                return new URI(localRepository.root().getParent().toUri().toString() + fileName);\n            } else {\n                return URI.create(\"localhost\");\n            }\n        } catch (IOException | URISyntaxException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    @Override\n    public URI webUrl() {\n        return authenticatedUrl();\n    }\n\n    @Override\n    public URI nonTransformedWebUrl() {\n        return authenticatedUrl();\n    }\n\n    @Override\n    public URI webUrl(Hash hash) {\n        return URI.create(authenticatedUrl().toString() + \"/\" + hash.hex());\n    }\n\n    @Override\n    public URI webUrl(String baseRef, String headRef) {\n        return URI.create(authenticatedUrl().toString() + \"/\" + baseRef + \"...\" + headRef);\n    }\n\n    @Override\n    public URI diffUrl(String prId) {\n        return webUrl();\n    }\n\n    @Override\n    public VCS repositoryType() {\n        return VCS.GIT;\n    }\n\n    @Override\n    public URI url() {\n        return authenticatedUrl();\n    }\n\n    @Override\n    public Optional<String> fileContents(String filename, String ref) {\n        try {\n            var bytes = localRepository.show(Path.of(filename), localRepository.resolve(ref).orElseThrow());\n            return bytes.map(b -> new String(b, StandardCharsets.UTF_8));\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        } catch (NoSuchElementException e) {\n            return Optional.empty();\n        }\n    }\n\n    @Override\n    public void writeFileContents(String filename, String content, Branch branch, String message, String authorName, String authorEmail, boolean createNewFile) {\n        try {\n            localRepository.checkout(branch);\n            Path absPath = localRepository.root().resolve(filename);\n            Files.createDirectories(absPath.getParent());\n\n            if (createNewFile) {\n                // Attempt to create a new file, throw exception if it already exists\n                if (Files.exists(absPath)) {\n                    throw new FileAlreadyExistsException(\"File already exists: \" + absPath);\n                }\n                Files.writeString(absPath, content, StandardOpenOption.CREATE_NEW);\n            } else {\n                // Attempt to update an existing file, throw exception if it doesn't exist\n                if (!Files.exists(absPath)) {\n                    throw new NoSuchFileException(\"File does not exist: \" + absPath);\n                }\n                Files.writeString(absPath, content, StandardOpenOption.TRUNCATE_EXISTING);\n            }\n\n            localRepository.add(absPath);\n            var hash = localRepository.commit(message, authorName, authorEmail);\n            // Don't leave the repository having a branch checked out as that would\n            // prevent pushing to that branch.\n            localRepository.checkout(hash);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public String namespace() {\n        return namespace;\n    }\n\n    /**\n     * Allow tests to user a different namespace\n     */\n    public void setNamespace(String namespace) {\n        this.namespace = namespace;\n    }\n\n    @Override\n    public Optional<WebHook> parseWebHook(JSONValue body) {\n        return Optional.empty();\n    }\n\n    @Override\n    public HostedRepository fork() {\n        throw new RuntimeException(\"not implemented yet\");\n    }\n\n    @Override\n    public long id() {\n        return 0L;\n    }\n\n    @Override\n    public Optional<Hash> branchHash(String ref) {\n        try {\n            return localRepository.resolve(ref);\n        } catch (IOException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    @Override\n    public List<HostedBranch> branches() {\n        try {\n            var result = new ArrayList<HostedBranch>();\n            for (var b : localRepository.branches()) {\n                result.add(new HostedBranch(b.name(), localRepository.resolve(b).orElseThrow()));\n            }\n            return result;\n        } catch (IOException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    @Override\n    public String defaultBranchName() {\n        return \"master\";\n    }\n\n    @Override\n    public void protectBranchPattern(String pattern) {\n        protectedBranchPatterns.add(pattern);\n    }\n\n    @Override\n    public void unprotectBranchPattern(String pattern) {\n        protectedBranchPatterns.remove(pattern);\n    }\n\n    @Override\n    public void deleteBranch(String ref) {\n        try {\n            for (String protectedBranchPattern : protectedBranchPatterns) {\n                var pattern = Pattern.compile(protectedBranchPattern.replace(\"*\", \".*\"));\n                if (pattern.matcher(ref).matches()) {\n                    throw new RuntimeException(\"Branch \" + ref + \" is protected with pattern '\"\n                            + protectedBranchPattern + \"' and cannot be removed\");\n                }\n            }\n            localRepository.delete(new Branch(ref));\n        } catch (IOException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    @Override\n    public List<CommitComment> commitComments(Hash hash) {\n        if (!commitComments.containsKey(hash)) {\n            return List.of();\n        }\n        return commitComments.get(hash);\n    }\n\n    @Override\n    public List<CommitComment> recentCommitComments(ReadOnlyRepository unused, Set<Integer> excludeAuthors,\n            List<Branch> branches, ZonedDateTime updatedAfter) {\n        return commitComments.values()\n                             .stream()\n                             .flatMap(e -> e.stream())\n                             .sorted((c1, c2) -> c2.updatedAt().compareTo(c1.updatedAt()))\n                             .filter(c -> !excludeAuthors.contains(Integer.valueOf(c.author().id())))\n                             .filter(c -> c.updatedAt().isAfter(updatedAfter))\n                             .collect(Collectors.toList());\n    }\n\n    @Override\n    public CommitComment addCommitComment(Hash hash, String body) {\n        var createdAt = ZonedDateTime.now();\n        var id = createdAt.toInstant().toString();\n\n        if (!commitComments.containsKey(hash)) {\n            commitComments.put(hash, new ArrayList<>());\n        }\n        var comments = commitComments.get(hash);\n        var comment = new CommitComment(hash, null, -1, id, body, host.currentUser(), createdAt, createdAt);\n        comments.add(comment);\n        return comment;\n    }\n\n    @Override\n    public void updateCommitComment(String id, String body) {\n        throw new RuntimeException(\"not implemented yet\");\n    }\n\n    @Override\n    public Optional<HostedCommit> commit(Hash hash, boolean includeDiffs) {\n        try {\n            var commit = localRepository.lookup(hash);\n            if (!commit.isPresent()) {\n                return Optional.empty();\n            }\n            var url = URI.create(\"file://\" + localRepository.root() + \"/commits/\" + hash.hex());\n            List<Diff> parentDiffs = includeDiffs ? commit.get().parentDiffs() : List.of();\n            return Optional.of(new HostedCommit(commit.get().metadata(), parentDiffs, url));\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public List<Check> allChecks(Hash hash) {\n        return checks.stream()\n                .filter(check -> check.hash().equals(hash))\n                .toList();\n    }\n\n    public void createCheck(Check check) {\n        var existing = checks.stream()\n                .filter(c -> c.name().equals(check.name()))\n                .findAny();\n        existing.ifPresent(checks::remove);\n        checks.add(check);\n    }\n\n    @Override\n    public WorkflowStatus workflowStatus() {\n        return WorkflowStatus.ENABLED;\n    }\n\n    @Override\n    public URI createPullRequestUrl(HostedRepository target, String sourceRef, String targetRef) {\n        return URI.create(target.webUrl().toString() + \"/pull/new/\" + targetRef + \"...\" + projectName + \":\" + sourceRef);\n    }\n\n    @Override\n    public URI webUrl(Branch branch) {\n        return URI.create(webUrl() + \"/branch/\" + branch.name());\n    }\n\n    @Override\n    public URI webUrl(Tag tag) {\n        return URI.create(webUrl() + \"/tag/\" + tag.name());\n    }\n\n    @Override\n    public List<Collaborator> collaborators() {\n        return collaborators;\n    }\n\n    @Override\n    public void addCollaborator(HostUser user, boolean canPush) {\n        collaborators.add(new Collaborator(user, canPush));\n    }\n\n    @Override\n    public void removeCollaborator(HostUser user) {\n        var toRemove = collaborators.stream()\n                .filter(c -> c.user().equals(user))\n                .toList();\n        toRemove.forEach(collaborators::remove);\n    }\n\n    @Override\n    public boolean canPush(HostUser user) {\n        return collaborators.stream()\n                .filter(c -> c.user().equals(user))\n                .findFirst()\n                .map(Collaborator::canPush)\n                .orElse(false);\n    }\n\n    @Override\n    public void restrictPushAccess(Branch branch, HostUser user) {\n        // Not possible to simulate\n    }\n\n    Repository localRepository() {\n        return localRepository;\n    }\n\n    @Override\n    public List<Label> labels() {\n        return labels;\n    }\n\n    @Override\n    public void addLabel(Label label) {\n        labels.add(label);\n    }\n\n    @Override\n    public void updateLabel(Label label) {\n        var existingLabel = labels.stream().filter(l -> l.name().equals(label.name())).findAny();\n        existingLabel.ifPresent(value -> labels.remove(value));\n        labels.add(label);\n    }\n\n    @Override\n    public void deleteLabel(Label label) {\n        var existingLabel = labels.stream().filter(l -> l.name().equals(label.name())).findAny();\n        existingLabel.ifPresent(value -> labels.remove(value));\n    }\n\n    @Override\n    public int deleteDeployKeys(Duration age) {\n        var expiredKeys = deployKeys.entrySet()\n                .stream()\n                .filter(entry -> entry.getValue().isBefore(ZonedDateTime.now().minus(age)))\n                .toList();\n        for (var key : expiredKeys) {\n            deployKeys.remove(key.getKey());\n        }\n        return expiredKeys.size();\n    }\n\n    @Override\n    public List<String> deployKeyTitles(Duration age) {\n        return deployKeys.entrySet()\n                .stream()\n                .filter(entry -> entry.getValue().isBefore(ZonedDateTime.now().minus(age)))\n                .map(Map.Entry::getKey)\n                .toList();\n    }\n\n    public void addDeployKeys(String title, ZonedDateTime createTime) {\n        deployKeys.put(title, createTime);\n    }\n\n    public Map<String, ZonedDateTime> deployKeys() {\n        return deployKeys;\n    }\n\n    @Override\n    public boolean canCreatePullRequest(HostUser user) {\n        return true;\n    }\n\n    @Override\n    public List<PullRequest> openPullRequestsWithTargetRef(String targetRef) {\n        return host.getPullRequests(this).stream()\n                .filter(pr -> pr.state().equals(Issue.State.OPEN))\n                .filter(pr -> pr.targetRef.equals(targetRef))\n                .collect(Collectors.toList());\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TestIssue.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport java.net.URI;\nimport java.time.ZonedDateTime;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.stream.Collectors;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.issuetracker.IssueProject;\nimport org.openjdk.skara.issuetracker.Label;\nimport org.openjdk.skara.network.URIBuilder;\n\n/**\n * Base class with common functionality for TestIssueTrackerIssue and TestPullRequest\n */\npublic class TestIssue implements Issue {\n    private final TestIssueStore store;\n    protected final HostUser user;\n\n    protected final HostUser author;\n    protected final String body;\n    protected final String title;\n    protected final State state;\n    // Labels are cached but still kept up to date\n    private List<Label> labels;\n    protected ZonedDateTime lastUpdate;\n\n    protected TestIssue(TestIssueStore store, HostUser user) {\n        this.store = store;\n        this.user = user;\n        this.lastUpdate = store.lastUpdate();\n        this.state = store.state();\n        this.author = store.author();\n        this.body = store.body();\n        this.title = store.title();\n        this.labels = store.labels().keySet().stream().map(Label::new).collect(Collectors.toList());\n    }\n\n    @Override\n    public IssueProject project() {\n        return store.issueProject();\n    }\n\n    @Override\n    public String id() {\n        return store.id();\n    }\n\n    @Override\n    public HostUser author() {\n        return store.author();\n    }\n\n    @Override\n    public String title() {\n        return store.title().strip();\n    }\n\n    @Override\n    public void setTitle(String title) {\n        // the strip simulates gitlab behavior\n        store.setTitle(title.strip());\n        store.setLastUpdate(ZonedDateTime.now());\n    }\n\n    @Override\n    public String body() {\n        return body;\n    }\n\n    @Override\n    public void setBody(String body) {\n        store.setBody(body);\n        store.setLastUpdate(ZonedDateTime.now());\n    }\n\n    @Override\n    public List<Comment> comments() {\n        return List.copyOf(store.comments());\n    }\n\n    @Override\n    public Comment addComment(String body) {\n        List<Comment> comments = store.comments();\n        var size = comments.size();\n        var lastId = size > 0 ? comments.get(size - 1).id() : null;\n        var comment = new Comment(String.valueOf(lastId != null ? Integer.parseInt(lastId) + 1 : 0),\n                body,\n                user,\n                ZonedDateTime.now(),\n                ZonedDateTime.now());\n        store.comments().add(comment);\n        store.setLastUpdate(ZonedDateTime.now());\n        return comment;\n    }\n\n    @Override\n    public void removeComment(Comment comment) {\n        store.comments().remove(comment);\n    }\n\n    @Override\n    public Comment updateComment(String id, String body) {\n        var originalComment = store.comments().stream()\n                .filter(comment -> comment.id().equals(id)).findAny().orElseThrow();\n        var index = comments().indexOf(originalComment);\n        var comment = new Comment(originalComment.id(),\n                body,\n                originalComment.author(),\n                originalComment.createdAt(),\n                ZonedDateTime.now());\n        store.comments().set(index, comment);\n        store.setLastUpdate(ZonedDateTime.now());\n        return comment;\n    }\n\n    @Override\n    public URI commentUrl(Comment comment) {\n        return URIBuilder.base(webUrl()).appendPath(\"?focusedCommentId=\" + comment.id()).build();\n    }\n\n    @Override\n    public ZonedDateTime createdAt() {\n        return store.created();\n    }\n\n    @Override\n    public ZonedDateTime updatedAt() {\n        return lastUpdate;\n    }\n\n    @Override\n    public State state() {\n        return state;\n    }\n\n    @Override\n    public void setState(State state) {\n        store.setState(state);\n        store.setLastUpdate(ZonedDateTime.now());\n        store.setLastTouchedTime(ZonedDateTime.now());\n        store.setClosedBy(user);\n    }\n\n   @Override\n    public boolean isFixed() {\n        return isResolved() || isClosed();\n    }\n\n    @Override\n    public void addLabel(String label) {\n        labels = null;\n        var now = ZonedDateTime.now();\n        store.labels().put(label, now);\n        store.setLastUpdate(now);\n    }\n\n    @Override\n    public void removeLabel(String label) {\n        labels = null;\n        store.labels().remove(label);\n        store.setLastUpdate(ZonedDateTime.now());\n    }\n\n    @Override\n    public void setLabels(List<String> labels) {\n        store.labels().clear();\n        var now = ZonedDateTime.now();\n        for (var label : labels) {\n            store.labels().put(label, now);\n        }\n        store.setLastUpdate(ZonedDateTime.now());\n        this.labels = labels.stream().map(Label::new).collect(Collectors.toList());\n    }\n\n    @Override\n    public List<Label> labels() {\n        if (labels == null) {\n            labels = store.labels().keySet().stream().map(Label::new).collect(Collectors.toList());\n        }\n        return labels;\n    }\n\n    @Override\n    public URI webUrl() {\n        return URIBuilder.base(store.issueProject().webUrl()).appendPath(id()).build();\n    }\n\n    @Override\n    public List<HostUser> assignees() {\n        return new ArrayList<>(store.assignees());\n    }\n\n    @Override\n    public void setAssignees(List<HostUser> assignees) {\n        store.assignees().clear();\n        store.assignees().addAll(assignees);\n        store.setLastUpdate(ZonedDateTime.now());\n    }\n\n    @Override\n    public Optional<HostUser> closedBy() {\n        return isClosed() ? Optional.of(store.closedBy()) : Optional.empty();\n    }\n\n    public void setLastUpdate(ZonedDateTime time) {\n        lastUpdate = time;\n    }\n\n    /**\n     * Equals for a TestIssue means that all the snapshotted data is the same.\n     */\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        var testIssue = (TestIssue) o;\n        return Objects.equals(store.id(), testIssue.store.id()) &&\n                Objects.equals(author, testIssue.author) &&\n                Objects.equals(body, testIssue.body) &&\n                Objects.equals(title, testIssue.title) &&\n                Objects.equals(lastUpdate, testIssue.lastUpdate) &&\n                Objects.equals(labels, testIssue.labels) &&\n                state == testIssue.state;\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(store.id(), author, body, title, lastUpdate, labels, state);\n    }\n\n    /**\n     * Gives test code direct access to the backing store object to be able to\n     * inspect and manipulate state directly.\n     */\n    public TestIssueStore store() {\n        return store;\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TestIssueProject.java",
    "content": "/*\n * Copyright (c) 2019, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport org.openjdk.skara.issuetracker.*;\nimport org.openjdk.skara.json.JSONValue;\nimport org.openjdk.skara.network.URIBuilder;\n\nimport java.net.URI;\nimport java.time.ZonedDateTime;\nimport java.util.*;\n\npublic class TestIssueProject implements IssueProject {\n    private final String projectName;\n    private final TestHost host;\n\n    String projectName() {\n        return projectName;\n    }\n\n    @Override\n    public IssueTracker issueTracker() {\n        return host;\n    }\n\n    @Override\n    public URI webUrl() {\n        return URIBuilder.base(\"http://localhost/project/\" + projectName).build();\n    }\n\n    public TestIssueProject(TestHost host, String projectName) {\n        this.host = host;\n        this.projectName = projectName;\n    }\n\n    @Override\n    public IssueTrackerIssue createIssue(String title, List<String> body, Map<String, JSONValue> properties) {\n        return host.createIssue(this, title, body, properties);\n    }\n\n    @Override\n    public Optional<IssueTrackerIssue> issue(String id) {\n        if (id.indexOf('-') < 0) {\n            id = projectName.toUpperCase() + \"-\" + id;\n        }\n\n        return Optional.ofNullable(host.getIssue(this, id));\n    }\n\n    @Override\n    public Optional<IssueTrackerIssue> jepIssue(String jepId) {\n        return Optional.ofNullable(host.getJepIssue(this, jepId));\n    }\n\n    @Override\n    public List<IssueTrackerIssue> issues() {\n        return new ArrayList<>(host.getIssues(this));\n    }\n\n    @Override\n    public List<IssueTrackerIssue> issues(ZonedDateTime updatedAfter) {\n        return new ArrayList<>(host.getIssues(this, updatedAfter));\n    }\n\n    @Override\n    public List<IssueTrackerIssue> csrIssues(ZonedDateTime updatedAfter) {\n        return new ArrayList<>(host.getCsrIssues(this, updatedAfter));\n    }\n\n    @Override\n    public Optional<IssueTrackerIssue> lastUpdatedIssue() {\n        return Optional.ofNullable(host.getLastUpdatedIssue(this).orElse(null));\n    }\n\n    @Override\n    public String name() {\n        return projectName.toUpperCase();\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TestIssueStore.java",
    "content": "/*\n * Copyright (c) 2022, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport java.time.ZonedDateTime;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.issuetracker.IssueProject;\n\n/**\n * Base class for backing store for issues. Represents the server side store of an Issue.\n */\npublic class TestIssueStore {\n    private final String id;\n    private final IssueProject issueProject;\n    private final HostUser author;\n\n    private Issue.State state = Issue.State.OPEN;\n    private String body = \"\";\n    private String title = \"\";\n    private final List<Comment> comments = new ArrayList<>();\n    private final Map<String, ZonedDateTime> labels = new HashMap<>();\n    private final List<HostUser> assignees = new ArrayList<>();\n    private final ZonedDateTime created = ZonedDateTime.now();\n    private ZonedDateTime lastUpdate = created;\n    private ZonedDateTime lastTouchedTime = created;\n    private HostUser closedBy = null;\n\n    public TestIssueStore(String id, IssueProject issueProject, HostUser author, String title, List<String> body) {\n        this.id = id;\n        this.issueProject = issueProject;\n        this.author = author;\n        this.title = title;\n        this.body = String.join(\"\\n\", body);\n    }\n\n    public String id() {\n        return id;\n    }\n\n    public IssueProject issueProject() {\n        return issueProject;\n    }\n\n    public HostUser author() {\n        return author;\n    }\n\n    public Issue.State state() {\n        return state;\n    }\n\n    public String body() {\n        return body;\n    }\n\n    public String title() {\n        return title;\n    }\n\n    public List<Comment> comments() {\n        return comments;\n    }\n\n    public Map<String, ZonedDateTime> labels() {\n        return labels;\n    }\n\n    public List<String> labelNames() {\n        return labels().keySet().stream().toList();\n    }\n\n    public List<HostUser> assignees() {\n        return assignees;\n    }\n\n    public ZonedDateTime created() {\n        return created;\n    }\n\n    public ZonedDateTime lastUpdate() {\n        return lastUpdate;\n    }\n\n    public HostUser closedBy() {\n        return closedBy;\n    }\n\n    public void setState(Issue.State state) {\n        this.state = state;\n    }\n\n    public void setBody(String body) {\n        this.body = body;\n    }\n\n    public void setTitle(String title) {\n        this.title = title;\n    }\n\n    public void setLastUpdate(ZonedDateTime lastUpdate) {\n        this.lastUpdate = lastUpdate;\n    }\n\n    public void setLastTouchedTime(ZonedDateTime lastTouchedTime) {\n        this.lastTouchedTime = lastTouchedTime;\n    }\n\n    public ZonedDateTime lastTouchedTime(){\n        return lastTouchedTime;\n    }\n\n    public void setClosedBy(HostUser closedBy) {\n        this.closedBy = closedBy;\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TestIssueTrackerIssue.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport java.time.ZonedDateTime;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.IssueTrackerIssue;\nimport org.openjdk.skara.issuetracker.Link;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.json.JSONValue;\n\n/**\n * TestIssueTrackerIssue is the object returned from a TestHost when queried for\n * issues. It's backed by a TestIssueStore, which tracks the \"server side\" state\n * of the issue. A TestIssue object contains a snapshot of the server side state\n * for all data directly related to the issue. What data is snapshotted and what\n * is fetched on request should be the same as for JiraIssue.\n */\npublic class TestIssueTrackerIssue extends TestIssue implements IssueTrackerIssue {\n\n    public TestIssueTrackerIssue(TestIssueTrackerIssueStore store, HostUser user) {\n        super(store, user);\n    }\n\n    /**\n     * Gives test code direct access to the backing store object to be able to\n     * inspect and manipulate state directly.\n     */\n    public TestIssueTrackerIssueStore store() {\n        return (TestIssueTrackerIssueStore) super.store();\n    }\n    private static final List<String> VALID_RESOLUTIONS = List.of(\"Fixed\", \"Delivered\");\n\n    @Override\n    public void setState(State state) {\n        super.setState(state);\n        if (state == State.RESOLVED || state == State.CLOSED) {\n            store().properties().put(\"resolution\", JSON.object().put(\"name\", JSON.of(\"Fixed\")));\n        }\n    }\n\n    @Override\n    public String status() {\n        return store().properties().get(\"status\").get(\"name\").asString();\n    }\n\n    @Override\n    public Optional<String> resolution() {\n        var resolution = store().properties().get(\"resolution\");\n        if (resolution != null && !resolution.isNull()) {\n            var name = resolution.get(\"name\");\n            if (name != null && !name.isNull()) {\n                return Optional.of(resolution.get(\"name\").asString());\n            }\n        }\n        return Optional.empty();\n    }\n\n    /**\n     * This implementation mimics the JiraIssue definition of isFixed and is\n     * needed to test handling of backports.\n     */\n    @Override\n    public boolean isFixed() {\n        if (super.isFixed()) {\n            return resolution().map(VALID_RESOLUTIONS::contains).orElse(Boolean.FALSE);\n        }\n        return false;\n    }\n\n    /**\n     * When links are returned, they need to contain fresh snapshots of any TestIssue.\n     */\n    @Override\n    public List<Link> links() {\n        return store().links().stream()\n                .map(this::updateLinkIssue)\n                .toList();\n    }\n\n    private Link updateLinkIssue(Link link) {\n        if (link.issue().isPresent()) {\n            var issue = (TestIssueTrackerIssue) link.issue().get();\n            return Link.create(issue.copy(), link.relationship().orElseThrow()).build();\n        } else {\n            return link;\n        }\n    }\n\n    protected TestIssueTrackerIssue copy() {\n        return new TestIssueTrackerIssue(store(), user);\n    }\n\n    @Override\n    public void addLink(Link link) {\n        if (link.uri().isPresent()) {\n            removeLink(link);\n            store().links().add(link);\n        } else if (link.issue().isPresent()) {\n            var existing = store().links().stream()\n                    .filter(l -> l.issue().isPresent() && l.issue().get().id().equals(link.issue().orElseThrow().id()))\n                    .findAny();\n            existing.ifPresent(store().links()::remove);\n            store().links().add(link);\n            if (existing.isEmpty()) {\n                var map = Map.of(\"backported by\", \"backport of\", \"backport of\", \"backported by\",\n                        \"csr for\", \"csr of\", \"csr of\", \"csr for\",\n                        \"blocks\", \"is blocked by\", \"is blocked by\", \"blocks\",\n                        \"clones\", \"is cloned by\", \"is cloned by\", \"clones\");\n                var reverseRelationship = map.getOrDefault(link.relationship().orElseThrow(), link.relationship().orElseThrow());\n                var reverse = Link.create(this, reverseRelationship).build();\n                link.issue().get().addLink(reverse);\n            }\n        } else {\n            throw new IllegalArgumentException(\"Can't add unknown link type: \" + link);\n        }\n        store().setLastUpdate(ZonedDateTime.now());\n    }\n\n    @Override\n    public void removeLink(Link link) {\n        if (link.uri().isPresent()) {\n            store().links().removeIf(l -> l.uri().equals(link.uri()));\n        } else if (link.issue().isPresent()) {\n            var existing = store().links().stream()\n                    .filter(l -> l.issue().orElseThrow().id().equals(link.issue().orElseThrow().id()))\n                    .findAny();\n            if (existing.isPresent()) {\n                store().links().remove(existing.get());\n                var reverse = Link.create(this, \"\").build();\n                link.issue().get().removeLink(reverse);\n            }\n        } else {\n            throw new IllegalArgumentException(\"Can't remove unknown link type: \" + link);\n        }\n        store().setLastUpdate(ZonedDateTime.now());\n    }\n\n    @Override\n    public Map<String, JSONValue> properties() {\n        return store().properties();\n    }\n\n    @Override\n    public void setProperty(String name, JSONValue value) {\n        store().properties().put(name, value);\n        store().setLastUpdate(ZonedDateTime.now());\n    }\n\n    @Override\n    public void removeProperty(String name) {\n        store().properties().remove(name);\n        store().setLastUpdate(ZonedDateTime.now());\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TestIssueTrackerIssueStore.java",
    "content": "/*\n * Copyright (c) 2022, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.issuetracker.Issue;\nimport org.openjdk.skara.issuetracker.IssueProject;\nimport org.openjdk.skara.issuetracker.Link;\nimport org.openjdk.skara.json.JSON;\nimport org.openjdk.skara.json.JSONValue;\n\n/**\n * Backing store for TestIssueTrackerIssue. Represents the \"server side\" state of an Issue.\n */\npublic class TestIssueTrackerIssueStore extends TestIssueStore {\n\n    private final List<Link> links = new ArrayList<>();\n    private final Map<String, JSONValue> properties = new HashMap<>();\n\n    public TestIssueTrackerIssueStore(String id, IssueProject issueProject, HostUser author, String title,\n            List<String> body, Map<String, JSONValue> properties) {\n        super(id, issueProject, author, title, body);\n        // Set defaults for some expected mandatory fields\n        this.properties.put(\"status\", JSON.object().put(\"name\", JSON.of(\"New\")));\n        this.properties.put(\"priority\", JSON.of(\"3\"));\n        this.properties.put(\"issuetype\", JSON.of(\"Bug\"));\n        if (properties != null) {\n            this.properties.putAll(properties);\n        }\n    }\n\n    /**\n     * Use the underlying status of the issue for state to better mimic JiraIssue\n     */\n    @Override\n    public Issue.State state() {\n        return switch (properties().get(\"status\").get(\"name\").asString()) {\n            case \"Closed\" -> Issue.State.CLOSED;\n            case \"Resolved\" -> Issue.State.RESOLVED;\n            default -> Issue.State.OPEN;\n        };\n    }\n\n    /**\n     * Use the underlying status of the issue for state to better mimic JiraIssue\n     */\n    @Override\n    public void setState(Issue.State state) {\n        var newStatus = switch (state) {\n            case CLOSED -> \"Closed\";\n            case RESOLVED -> \"Resolved\";\n            default -> \"Open\";\n        };\n        properties().put(\"status\", JSON.object().put(\"name\", JSON.of(newStatus)));\n    }\n\n    public List<Link> links() {\n        return links;\n    }\n\n    public Map<String, JSONValue> properties() {\n        return properties;\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TestMailmanServer.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport com.sun.net.httpserver.*;\nimport java.time.LocalDate;\nimport java.time.ZoneId;\nimport java.time.ZonedDateTime;\nimport java.time.format.DateTimeFormatter;\nimport java.util.zip.GZIPOutputStream;\nimport org.openjdk.skara.email.*;\nimport org.openjdk.skara.mailinglist.Mbox;\nimport org.openjdk.skara.network.URIBuilder;\n\nimport java.io.*;\nimport java.net.*;\nimport java.nio.charset.StandardCharsets;\nimport java.security.*;\nimport java.time.Duration;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\npublic abstract class TestMailmanServer implements AutoCloseable {\n    protected final HttpServer httpServer;\n    private final SMTPServer smtpServer;\n    private int callCount = 0;\n    private boolean lastResponseCached;\n\n    public static TestMailmanServer createV2() throws IOException {\n        return new TestMailman2Server();\n    }\n\n    public static TestMailmanServer createV3() throws IOException {\n        return new TestMailman3Server();\n    }\n\n    private class Handler implements HttpHandler {\n        @Override\n        public void handle(HttpExchange exchange) throws IOException {\n            callCount++;\n            var mboxContents = getMboxContents(exchange);\n            if (mboxContents == null) {\n                exchange.sendResponseHeaders(404, 0);\n                exchange.close();\n                return;\n            }\n            lastResponseCached = false;\n\n            try {\n                var digest = MessageDigest.getInstance(\"SHA-256\");\n                digest.update(mboxContents);\n                var etag = \"\\\"\" + Base64.getUrlEncoder().encodeToString(digest.digest()) + \"\\\"\";\n                exchange.getResponseHeaders().add(\"ETag\", etag);\n\n                if (exchange.getRequestHeaders().containsKey(\"If-None-Match\")) {\n                    if (exchange.getRequestHeaders().getFirst(\"If-None-Match\").equals(etag)) {\n                        lastResponseCached = true;\n                        exchange.sendResponseHeaders(304, 0);\n                        return;\n                    }\n                }\n\n                exchange.sendResponseHeaders(200, mboxContents.length);\n                OutputStream outputStream = exchange.getResponseBody();\n                outputStream.write(mboxContents);\n                outputStream.close();\n            } catch (NoSuchAlgorithmException e) {\n                throw new RuntimeException(e);\n            }\n        }\n    }\n\n    protected TestMailmanServer() throws IOException {\n        InetSocketAddress address = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0);\n        httpServer = HttpServer.create(address, 0);\n        httpServer.createContext(\"/test\", new Handler());\n        httpServer.setExecutor(null);\n        httpServer.start();\n\n        smtpServer = new SMTPServer();\n    }\n\n    protected abstract byte[] getMboxContents(HttpExchange exchange);\n\n    public URI getArchive() {\n        return URIBuilder.base(\"http://\" + httpServer.getAddress().getHostString() + \":\" +  httpServer.getAddress().getPort() + \"/test/\").build();\n    }\n\n    public String getSMTP() {\n        return smtpServer.address();\n    }\n\n    public abstract EmailAddress createList(String name);\n\n    public void processIncoming(Duration timeout) {\n        var email = smtpServer.receive(timeout);\n        var subject = email.subject();\n        if (subject.startsWith(\"Re: \")) {\n            subject = subject.substring(4);\n        }\n        var stripped = Email.from(email)\n                            .subject(subject)\n                            .build();\n        var mboxEntry = Mbox.fromMail(stripped);\n\n        archiveEmail(email, mboxEntry);\n    }\n\n    protected abstract void archiveEmail(Email email, String mboxEntry);\n\n    public void processIncoming() {\n        processIncoming(Duration.ofSeconds(10));\n    }\n\n    @Override\n    public void close() throws IOException {\n        httpServer.stop(0);\n        smtpServer.close();\n    }\n\n    public boolean lastResponseCached() {\n        return lastResponseCached;\n    }\n\n    public void resetCallCount() {\n        callCount = 0;\n    }\n\n    public int callCount() {\n        return callCount;\n    }\n}\n\nclass TestMailman2Server extends TestMailmanServer {\n\n    private static final Pattern listPathPattern = Pattern.compile(\"^/test/(.*?)/(.*)\\\\.txt\");\n    // Map from local part of email list name to map from date string to mbox contents\n    private final Map<EmailAddress, Map<String, StringBuilder>> lists = new HashMap<>();\n\n    public TestMailman2Server() throws IOException {\n        super();\n    }\n\n    @Override\n    protected void archiveEmail(Email email, String mboxEntry) {\n        var listMap = email.recipients().stream()\n                            .filter(lists::containsKey)\n                            .map(lists::get)\n                            .findAny().orElseThrow();\n        var datePath = DateTimeFormatter.ofPattern(\"yyyy-MMMM\", Locale.US).format(email.date());\n        if (!listMap.containsKey(datePath)) {\n            listMap.put(datePath, new StringBuilder());\n        }\n        listMap.get(datePath).append(mboxEntry);\n    }\n\n    @Override\n    protected byte[] getMboxContents(HttpExchange exchange) {\n        var listMatcher = listPathPattern.matcher(exchange.getRequestURI().getPath());\n        if (!listMatcher.matches()) {\n            throw new RuntimeException();\n        }\n        var listPath = listMatcher.group(1);\n        var datePath = listMatcher.group(2);\n        var listMap = lists.get(EmailAddress.parse(listPath + \"@\" + httpServer.getAddress().getHostString()));\n        var contents = listMap.get(datePath);\n        if (contents != null) {\n            return contents.toString().getBytes(StandardCharsets.UTF_8);\n        } else {\n            return null;\n        }\n    }\n\n    @Override\n    public EmailAddress createList(String name) {\n        var listName = EmailAddress.parse(name + \"@\" + httpServer.getAddress().getHostString());\n        lists.put(listName, new HashMap<>());\n        return listName;\n    }\n}\n\nclass TestMailman3Server extends TestMailmanServer {\n\n    private record EmailEntry(Email email, String mbox) {}\n\n    private Map<EmailAddress, List<EmailEntry>> lists = new HashMap<>();\n\n    private static final Pattern listPathPattern = Pattern.compile(\"^/test/list/(.*?)/export/(.*)\\\\.mbox.gz\");\n\n    protected TestMailman3Server() throws IOException {\n        super();\n    }\n\n    @Override\n    protected byte[] getMboxContents(HttpExchange exchange) {\n        // https://mail-dev.example.com/archives/list/skara-test@mail-dev.example.com/export/foo.mbox.gz?start=2024-10-25&end=2025-10-25\n        var listMatcher = listPathPattern.matcher(exchange.getRequestURI().getPath());\n        if (!listMatcher.matches()) {\n            throw new RuntimeException();\n        }\n        var listPath = EmailAddress.parse(listMatcher.group(1));\n\n        var query = exchange.getRequestURI().getRawQuery();\n        String[] pairs = query.split(\"&\");\n        ZonedDateTime start = null;\n        ZonedDateTime end = null;\n        for (String pair : pairs) {\n            int i = pair.indexOf(\"=\");\n            if (i > 0) {\n                String key = URLDecoder.decode(pair.substring(0, i), StandardCharsets.UTF_8);\n                String value = URLDecoder.decode(pair.substring(i + 1), StandardCharsets.UTF_8);\n                if (\"start\".equals(key)) {\n                    start = LocalDate.parse(value).atStartOfDay(ZoneId.systemDefault());\n                } else if (\"end\".equals(key)) {\n                    end = LocalDate.parse(value).atStartOfDay(ZoneId.systemDefault());\n                }\n            } else {\n                throw new RuntimeException();\n            }\n        }\n        var entryList = lists.get(listPath);\n        var mbox = new StringBuilder();\n        var startDate = start;\n        var endDate = end;\n        entryList.stream()\n                .filter(e -> startDate == null || startDate.isBefore(e.email.date()))\n                .filter(e -> endDate == null || endDate.isAfter(e.email.date()))\n                .forEach(e -> mbox.append(e.mbox));\n\n        var zipped = new ByteArrayOutputStream();\n        try (var out = new OutputStreamWriter(new GZIPOutputStream(zipped))) {\n            out.write(mbox.toString());\n        } catch (IOException e) {\n            throw new RuntimeException(e);\n        }\n        return zipped.toByteArray();\n    }\n\n    @Override\n    public EmailAddress createList(String name) {\n        var emailAddress = EmailAddress.parse(name + \"@\" + httpServer.getAddress().getHostString());\n        lists.put(emailAddress, new ArrayList<>());\n        return emailAddress;\n    }\n\n    @Override\n    protected void archiveEmail(Email email, String mboxEntry) {\n        var entryList = email.recipients().stream()\n                .filter(recipient -> lists.containsKey(recipient))\n                .map(recipient -> lists.get(recipient))\n                .findAny().orElseThrow();\n        entryList.add(new EmailEntry(email, mboxEntry));\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TestProperties.java",
    "content": "/*\n * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.Properties;\n\npublic class TestProperties {\n    private static Properties PROPERTIES;\n    private static Path FILE;\n\n    static final String FILENAME = \"test.properties\";\n\n    private TestProperties() {\n    }\n\n    private static Path findFileUpToRoot(String filename) {\n        var dir = Path.of(\".\").toAbsolutePath();\n        var f = dir.resolve(filename);\n        while (!Files.exists(f)) {\n            dir = dir.getParent();\n            if (dir == null) {\n                return null;\n            }\n            f = dir.resolve(filename);\n        }\n        return f;\n    }\n\n    private static Properties load(Path f) throws IOException {\n        var properties = new Properties();\n        try (InputStream in = Files.newInputStream(f)) {\n            properties.load(in);\n        }\n        if (properties.getProperty(\"properties.include\") != null) {\n            var includedFile = Path.of(properties.getProperty(\"properties.include\"));\n            if (!includedFile.isAbsolute()) {\n                throw new IOException(\"Cannot use relative paths for including properties: \" + includedFile);\n            }\n            var included = load(includedFile);\n            for (var key : included.keySet()) {\n                // Allow included properties to be overridden\n                if (properties.getProperty((String) key) == null) {\n                    properties.setProperty((String) key, included.getProperty((String) key));\n                }\n            }\n        }\n        return properties;\n    }\n\n    public static TestProperties load() {\n        // Only load properties once (no need to use locking, races are benign)\n        if (PROPERTIES != null) {\n            return new TestProperties();\n        }\n\n        FILE = findFileUpToRoot(FILENAME);\n        if (FILE == null) {\n            return new TestProperties();\n        }\n        try {\n            PROPERTIES = load(FILE);\n            return new TestProperties();\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    public boolean arePresent() {\n        return PROPERTIES != null;\n    }\n\n    public String get(String key) {\n        if (!arePresent()) {\n            throw new IllegalStateException(\"Test properties have not been loaded\");\n        }\n        if (!contains(key)) {\n            throw new IllegalArgumentException(\"Could not find key '\" + key + \"' in: \" + FILE);\n        }\n        return PROPERTIES.getProperty(key);\n    }\n\n    public boolean contains(String... keys) {\n        if (!arePresent()) {\n            return false;\n        }\n\n        for (var key : keys) {\n            if (PROPERTIES.getProperty(key) == null) {\n                return false;\n            }\n        }\n\n        return true;\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TestPullRequest.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.issuetracker.Comment;\nimport org.openjdk.skara.issuetracker.Label;\nimport org.openjdk.skara.network.URIBuilder;\nimport org.openjdk.skara.vcs.Diff;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.io.*;\nimport java.net.*;\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\n\n/**\n * TestPullRequest is the object returned from a TestHost when queried for pull\n * requests. It's backed by a TestPullRequestStore, which tracks the \"server\n * side\" state of the pull request. A TestPullRequest instance contains a\n * snapshot of the server side state for all data directly related to the pull\n * request. What data is snapshotted and which is fetched on request should be\n * the same as for GitHubPullRequest and GitLabMergeRequest.\n */\npublic class TestPullRequest extends TestIssue implements PullRequest {\n\n    protected final TestHostedRepository targetRepository;\n    protected final Hash headHash;\n    protected final String sourceRef;\n    protected final String targetRef;\n    protected final boolean draft;\n\n    public TestPullRequest(TestPullRequestStore store, TestHostedRepository targetRepository) {\n        super(store, targetRepository.forge().currentUser());\n        this.targetRepository = targetRepository;\n        this.headHash = store().headHash();\n        this.sourceRef = store().sourceRef();\n        this.targetRef = store().targetRef();\n        this.draft = store().draft();\n        // store().headHash() may have updated lastUpdate\n        setLastUpdate(store().lastUpdate());\n    }\n\n    /**\n     * Gives test code direct access to the backing store object to be able to\n     * inspect and manipulate state directly.\n     */\n    public TestPullRequestStore store() {\n        return (TestPullRequestStore) super.store();\n    }\n\n    @Override\n    public HostedRepository repository() {\n        return targetRepository;\n    }\n\n    @Override\n    public List<Review> reviews() {\n        return List.copyOf(PullRequest.calculateReviewTargetRefs(store().reviews(), targetRefChanges()));\n    }\n\n    @Override\n    public void addReview(Review.Verdict verdict, String body) {\n        try {\n            var review = new Review(ZonedDateTime.now(), user,\n                                    verdict, targetRepository.localRepository().resolve(store().sourceRef()).orElseThrow(),\n                                    String.valueOf(store().reviews().size()),\n                                    body, targetRef);\n\n            store().reviews().add(review);\n            store().setLastUpdate(ZonedDateTime.now());\n\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public void updateReview(String id, String body) {\n        throw new RuntimeException(\"not implemented yet\");\n    }\n\n    @Override\n    public ReviewComment addReviewComment(Hash base, Hash hash, String path, int line, String body) {\n        var id = String.valueOf(store().reviewComments().size());\n        var comment = new ReviewComment(null, id,\n                hash, path, line, id, body, user, ZonedDateTime.now(), ZonedDateTime.now());\n        store().reviewComments().add(comment);\n        store().setLastUpdate(ZonedDateTime.now());\n        return comment;\n    }\n\n    @Override\n    public ReviewComment addReviewCommentReply(ReviewComment parent, String body) {\n        if (parent.parent().isPresent()) {\n            throw new RuntimeException(\"Can only reply to top-level review comments\");\n        }\n        var comment = new ReviewComment(parent, parent.threadId(), parent.hash().orElseThrow(), parent.path(),\n                parent.line(), String.valueOf(store().reviewComments().size()), body, user,\n                ZonedDateTime.now(), ZonedDateTime.now());\n        store().reviewComments().add(comment);\n        store().setLastUpdate(ZonedDateTime.now());\n        return comment;\n    }\n\n    @Override\n    public List<ReviewComment> reviewComments() {\n        return new ArrayList<>(store().reviewComments());\n    }\n\n    @Override\n    public Hash headHash() {\n        return headHash;\n    }\n\n    @Override\n    public String fetchRef() {\n        return sourceRef;\n    }\n\n    @Override\n    public String sourceRef() {\n        return sourceRef;\n    }\n\n    @Override\n    public Optional<HostedRepository> sourceRepository() {\n        return Optional.of(store().sourceRepository());\n    }\n\n    @Override\n    public String targetRef() {\n        return targetRef;\n    }\n\n    @Override\n    public List<ReferenceChange> targetRefChanges() {\n        return store().targetRefChanges();\n    }\n\n    @Override\n    public Map<String, Check> checks(Hash hash) {\n        return store().checks().stream()\n                .filter(check -> check.hash().equals(hash))\n                .collect(Collectors.toMap(Check::name, Function.identity()));\n    }\n\n    @Override\n    public void createCheck(Check check) {\n        var checks = store().checks();\n        var existing = checks.stream()\n                                  .filter(c -> c.name().equals(check.name()))\n                                  .findAny();\n        existing.ifPresent(checks::remove);\n        checks.add(check);\n        store().setLastUpdate(ZonedDateTime.now());\n    }\n\n    @Override\n    public void updateCheck(Check updated) {\n        var checks = store().checks();\n        var existing = checks.stream()\n                .filter(check -> check.name().equals(updated.name()))\n                .findAny()\n                .orElseThrow();\n\n        checks.remove(existing);\n        checks.add(updated);\n        store().setLastUpdate(ZonedDateTime.now());\n    }\n\n    @Override\n    public URI changeUrl() {\n        return URIBuilder.base(webUrl()).appendPath(\"/files\").build();\n    }\n\n    @Override\n    public URI changeUrl(Hash base) {\n        return URIBuilder.base(webUrl()).appendPath(\"/files/\" + base.abbreviate()).build();\n    }\n\n    @Override\n    public URI commentUrl(Comment comment) {\n        return URIBuilder.base(webUrl()).appendPath(\"/comment/\" + comment.id()).build();\n    }\n\n    @Override\n    public URI reviewCommentUrl(ReviewComment reviewComment) {\n        return URIBuilder.base(webUrl()).appendPath(\"/reviewComment/\" + reviewComment.id()).build();\n    }\n\n    @Override\n    public URI reviewUrl(Review review) {\n        return URIBuilder.base(webUrl()).appendPath(\"/review/\" + review.id()).build();\n    }\n\n    @Override\n    public boolean isDraft() {\n        return draft;\n    }\n\n    @Override\n    public URI webUrl() {\n        try {\n            return new URI(targetRepository.webUrl().toString() + \"/pr/\" + id());\n        } catch (URISyntaxException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    @Override\n    public void makeNotDraft() {\n        store().setDraft(false);\n    }\n\n    public void makeDraft() {\n        store().setDraft(true);\n    }\n\n    @Override\n    public Optional<ZonedDateTime> lastMarkedAsDraftTime() {\n        return Optional.ofNullable(store().lastMarkedAsDraftTime());\n    }\n\n    @Override\n    public URI diffUrl() {\n        return URI.create(webUrl().toString() + \".diff\");\n    }\n\n    @Override\n    public Optional<ZonedDateTime> labelAddedAt(String label) {\n        return Optional.ofNullable(store().labels().get(label));\n    }\n\n    @Override\n    public void setTargetRef(String targetRef) {\n        store().setTargetRef(targetRef);\n        store().setLastUpdate(ZonedDateTime.now());\n    }\n\n    @Override\n    public URI headUrl() {\n        return URI.create(webUrl().toString() + \"/commits/\" + headHash().hex());\n    }\n\n    @Override\n    public Diff diff() {\n        if (store().returnCompleteDiff()) {\n            try {\n                var targetLocalRepository = targetRepository.localRepository();\n                var sourceLocalRepository = store().sourceRepository().localRepository();\n                var sourceHash = headHash();\n                if (!targetLocalRepository.root().equals(sourceLocalRepository.root())) {\n                    // The target and source repo are not same, fetch the source branch\n                    var sourceUri = URI.create(\"file://\" + sourceLocalRepository.root().toString());\n                    sourceHash = targetLocalRepository.fetch(sourceUri, sourceRef).orElseThrow();\n                }\n                // Find the base hash of the source and target branches.\n                var baseHash = targetLocalRepository.mergeBase(sourceHash, targetRepository.branchHash(targetRef()).orElseThrow());\n                return targetLocalRepository.diff(baseHash, sourceHash);\n            } catch (IOException e) {\n                throw new UncheckedIOException(e);\n            }\n        } else {\n            return new Diff(Hash.zero(), Hash.zero(), List.of(), false);\n        }\n    }\n\n    @Override\n    public URI filesUrl(Hash hash) {\n        return URI.create(webUrl().toString() + \"/files/\" + hash.hex());\n    }\n\n    @Override\n    public Optional<ZonedDateTime> lastForcePushTime() {\n        if (store().lastForcePushTime() != null && store().lastForcePushTime().isAfter(store().lastMarkedAsReadyTime())) {\n            return Optional.ofNullable(store().lastForcePushTime());\n        }\n        return Optional.empty();\n    }\n\n    public void setLastForcePushTime(ZonedDateTime lastForcePushTime) {\n        store().setLastForcePushTime(lastForcePushTime);\n    }\n\n    @Override\n    public Optional<Hash> findIntegratedCommitHash() {\n        return findIntegratedCommitHash(List.of(repository().forge().currentUser().id()));\n    }\n\n    @Override\n    public Object snapshot() {\n        return List.of(this, comments(), reviews());\n    }\n\n    /**\n     * Equals for a TestPullRequest means that all the snapshotted data is the same.\n     */\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        if (!super.equals(o)) {\n            return false;\n        }\n        TestPullRequest that = (TestPullRequest) o;\n        return draft == that.draft &&\n                Objects.equals(headHash, that.headHash) &&\n                Objects.equals(sourceRef, that.sourceRef) &&\n                Objects.equals(targetRef, that.targetRef);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(super.hashCode(), headHash, sourceRef, targetRef, draft);\n    }\n\n    public void setReturnCompleteDiff(boolean complete){\n        this.store().setReturnCompleteDiff(complete);\n    }\n\n    // For TestPullRequest, we control the lastUpdate timestamp, so it won't be spurious\n    @Override\n    public ZonedDateTime lastTouchedTime() {\n        return store().lastTouchedTime();\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TestPullRequestStore.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.time.ZonedDateTime;\nimport org.openjdk.skara.forge.*;\nimport org.openjdk.skara.host.HostUser;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.util.*;\n\n/**\n * Backing store for TestPullRequest. Represents the \"server side\" state of a\n * pull request.\n */\npublic class TestPullRequestStore extends TestIssueStore {\n    private TestHostedRepository sourceRepository;\n    private String targetRef;\n    private String sourceRef;\n    private final List<ReviewComment> reviewComments = new ArrayList<>();\n    private final Set<Check> checks = new HashSet<>();\n    private final List<Review> reviews = new ArrayList<>();\n    private boolean draft;\n    private ZonedDateTime lastForcePushTime;\n    private Hash headHash;\n    private final List<ReferenceChange> targetReferenceChanges = new ArrayList<>();\n\n    private ZonedDateTime lastMarkedAsReadyTime;\n    private ZonedDateTime lastMarkedAsDraftTime;\n    private boolean returnCompleteDiff;\n\n    public TestPullRequestStore(String id, HostUser author, String title, List<String> body,\n            TestHostedRepository sourceRepository, String targetRef, String sourceRef, boolean draft) {\n        super(id, null, author, title, body);\n        this.sourceRepository = sourceRepository;\n        this.targetRef = targetRef;\n        this.sourceRef = sourceRef;\n        this.draft = draft;\n        if (draft) {\n            lastMarkedAsDraftTime = ZonedDateTime.now();\n        } else {\n            lastMarkedAsReadyTime = ZonedDateTime.now();\n        }\n        this.returnCompleteDiff = true;\n    }\n\n    public TestHostedRepository sourceRepository() {\n        return sourceRepository;\n    }\n\n    /**\n     * Gets the current headHash from the underlying repository. If it has\n     * changed since last time, updates the lastUpdated timestamp.\n     */\n    public Hash headHash() {\n        if (sourceRepository.localRepository() != null) {\n            try {\n                var headHash = sourceRepository.localRepository().resolve(sourceRef);\n                if (headHash.isPresent() && !headHash.get().equals(this.headHash)) {\n                    this.headHash = headHash.get();\n                    setLastUpdate(ZonedDateTime.now());\n                    setLastTouchedTime(ZonedDateTime.now());\n                }\n            } catch (IOException e) {\n                throw new UncheckedIOException(e);\n            }\n        }\n        return this.headHash;\n    }\n\n    public String targetRef() {\n        return targetRef;\n    }\n\n    public String sourceRef() {\n        return sourceRef;\n    }\n\n    public List<ReviewComment> reviewComments() {\n        return reviewComments;\n    }\n\n    public Set<Check> checks() {\n        return checks;\n    }\n\n    public List<Review> reviews() {\n        return reviews;\n    }\n\n    public boolean draft() {\n        return draft;\n    }\n\n    public ZonedDateTime lastForcePushTime() {\n        return lastForcePushTime;\n    }\n\n    public boolean returnCompleteDiff(){\n        return returnCompleteDiff;\n    }\n\n    public void setSourceRepository(TestHostedRepository sourceRepository) {\n        this.sourceRepository = sourceRepository;\n    }\n\n    public void setTargetRef(String targetRef) {\n        targetReferenceChanges.add(new ReferenceChange(this.targetRef, targetRef, ZonedDateTime.now()));\n        this.targetRef = targetRef;\n    }\n\n    public List<ReferenceChange> targetRefChanges() {\n        return targetReferenceChanges;\n    }\n\n    public void setSourceRef(String sourceRef) {\n        this.sourceRef = sourceRef;\n    }\n\n    public void setDraft(boolean draft) {\n        this.draft = draft;\n        setLastUpdate(ZonedDateTime.now());\n        setLastTouchedTime(ZonedDateTime.now());\n        if (draft) {\n            lastMarkedAsDraftTime = ZonedDateTime.now();\n        } else {\n            lastMarkedAsReadyTime = ZonedDateTime.now();\n        }\n    }\n\n    public void setLastForcePushTime(ZonedDateTime lastForcePushTime) {\n        this.lastForcePushTime = lastForcePushTime;\n    }\n\n    public ZonedDateTime lastMarkedAsReadyTime() {\n        return lastMarkedAsReadyTime;\n    }\n\n    public ZonedDateTime lastMarkedAsDraftTime() {\n        return lastMarkedAsDraftTime;\n    }\n\n    public void setReturnCompleteDiff(boolean returnCompleteDiff) {\n        this.returnCompleteDiff = returnCompleteDiff;\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TestWebrevServer.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport com.sun.net.httpserver.*;\nimport org.openjdk.skara.network.URIBuilder;\n\nimport java.io.*;\nimport java.net.*;\nimport java.nio.charset.StandardCharsets;\nimport java.util.function.Consumer;\n\npublic class TestWebrevServer implements AutoCloseable {\n    private final HttpServer httpServer;\n    private boolean checked = false;\n    private boolean redirectFollowed = true;\n    private Consumer<URI> handleCallback = null;\n\n    private class Handler implements HttpHandler {\n        @Override\n        public void handle(HttpExchange exchange) throws IOException {\n            checked = true;\n            if (handleCallback != null) {\n                handleCallback.accept(exchange.getRequestURI());\n            }\n\n            var response = \"ok!\";\n            var responseBytes = response.getBytes(StandardCharsets.UTF_8);\n            if (!exchange.getRequestURI().toString().contains(\"final=true\")) {\n                exchange.getResponseHeaders().add(\"Location\", uri() + \"&final=true\");\n                exchange.sendResponseHeaders(302, responseBytes.length);\n            } else {\n                redirectFollowed = true;\n                exchange.sendResponseHeaders(200, responseBytes.length);\n            }\n            OutputStream outputStream = exchange.getResponseBody();\n            outputStream.write(responseBytes);\n            outputStream.close();\n        }\n    }\n\n    public TestWebrevServer() throws IOException {\n        InetSocketAddress address = new InetSocketAddress(InetAddress.getLoopbackAddress(), 0);\n        httpServer = HttpServer.create(address, 0);\n        httpServer.createContext(\"/webrevs\", new Handler());\n        httpServer.setExecutor(null);\n        httpServer.start();\n    }\n\n    public URI uri() {\n        return URIBuilder.base(\"http://\" + httpServer.getAddress().getHostString() + \":\" +  httpServer.getAddress().getPort() + \"/webrevs/\").build();\n    }\n\n    public boolean isChecked() {\n        return checked;\n    }\n\n    public boolean isRedirectFollowed() {\n        return redirectFollowed;\n    }\n\n    public void setHandleCallback(Consumer<URI> callback) {\n        if (handleCallback != null) {\n            throw new IllegalStateException(\"Can only set callback once\");\n        }\n        handleCallback = callback;\n    }\n\n    @Override\n    public void close() throws IOException {\n        httpServer.stop(0);\n    }\n}\n"
  },
  {
    "path": "test/src/main/java/org/openjdk/skara/test/TestableRepository.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.test;\n\nimport org.openjdk.skara.vcs.Repository;\nimport org.openjdk.skara.vcs.VCS;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\n\npublic class TestableRepository {\n    public static Repository init(Path p, VCS vcs) throws IOException {\n        Repository.ignoreConfiguration();\n        return Repository.init(p, vcs);\n    }\n}\n"
  },
  {
    "path": "test.dockerfile",
    "content": "# Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n#\n# This code is free software; you can redistribute it and/or modify it\n# under the terms of the GNU General Public License version 2 only, as\n# published by the Free Software Foundation.\n#\n# This code is distributed in the hope that it will be useful, but WITHOUT\n# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n# version 2 for more details (a copy is included in the LICENSE file that\n# accompanied this code).\n#\n# You should have received a copy of the GNU General Public License version\n# 2 along with this work; if not, write to the Free Software Foundation,\n# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n#\n# Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n# or visit www.oracle.com if you need additional information or have any\n# questions.\n\nFROM oraclelinux:7.6 as prerequisites-runtime\n\nWORKDIR /bots-build\n\nARG GIT_VERSION=2.19.3\nARG MERCURIAL_VERSION=4.7.2\n\nENV LANG en_US.UTF-8\nENV LANGUAGE en_US:en\nENV LC_ALL en_US.UTF-8\n\nRUN yum -y install make autoconf gcc curl-devel expat-devel gettext-devel openssl-devel perl-devel zlib-devel python-devel\nRUN curl -sSO https://www.mercurial-scm.org/release/mercurial-${MERCURIAL_VERSION}.tar.gz && \\\n    echo \"97f0594216f2348a2e37b2ad8a56eade044e741153fee8c584487e9934ca09fb  mercurial-4.7.2.tar.gz\" | sha256sum --check - && \\\n    tar xvfz mercurial-${MERCURIAL_VERSION}.tar.gz && \\\n    cd mercurial-${MERCURIAL_VERSION} && \\\n    python setup.py install --force --prefix=/bots/hg\nRUN curl -sSO https://mirrors.edge.kernel.org/pub/software/scm/git/git-${GIT_VERSION}.tar.xz && \\\n    echo \"0457f33eedd3f5e9fb9c2ea30bf455ed9915230e3800c632ff07e00ac2466ace git-${GIT_VERSION}.tar.xz\" | sha256sum --check - && \\\n    tar xvfJ git-${GIT_VERSION}.tar.xz && \\\n    cd git-${GIT_VERSION} && \\\n    make configure && \\\n    ./configure --prefix=/bots/git && \\\n    make all && \\\n    make install\n\n\nFROM oraclelinux:7.6\n\nWORKDIR /bots-build\n\nARG JAVA_OPTIONS\nARG GRADLE_OPTIONS\n\nENV LANG en_US.UTF-8\nENV LANGUAGE en_US:en\nENV LC_ALL en_US.UTF-8\n\nRUN yum -y install unzip rsync\n\nCOPY gradlew ./\nCOPY deps.env ./\nCOPY Unzip.java ./\n\nENV JAVA_TOOL_OPTIONS=$JAVA_OPTIONS\nRUN sh gradlew --no-daemon --version $GRADLE_OPTIONS\n\nCOPY --from=prerequisites-runtime /bots/git/ /bots/git/\nCOPY --from=prerequisites-runtime /bots/hg/ /bots/hg/\nCOPY ./ ./\n\nENV PATH=/bots/git/bin:/bots/hg/bin:${PATH}\nRUN sh gradlew --no-daemon $GRADLE_OPTIONS test\n"
  },
  {
    "path": "vcs/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.vcs'\n    test {\n        requires 'org.openjdk.skara.test'\n        requires 'org.junit.jupiter.api'\n        requires 'org.junit.jupiter.params'\n        opens 'org.openjdk.skara.vcs' to 'org.junit.platform.commons'\n        opens 'org.openjdk.skara.vcs.git' to 'org.junit.platform.commons'\n        opens 'org.openjdk.skara.vcs.openjdk' to 'org.junit.platform.commons'\n        opens 'org.openjdk.skara.vcs.openjdk.converter' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':process')\n    implementation project(':encoding')\n    testImplementation project(':test')\n}\n\ntask copyResources(type: Copy) {\n    from \"${projectDir}/src/main/resources\"\n    into \"${buildDir}/classes/java/test\"\n}\n\ntest {\n    dependsOn 'copyResources'\n}\n\npublishing {\n    publications {\n        vcs(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.vcs {\n    requires java.logging;\n    requires org.openjdk.skara.process;\n    requires org.openjdk.skara.encoding;\n\n    exports org.openjdk.skara.vcs;\n    exports org.openjdk.skara.vcs.git;\n    exports org.openjdk.skara.vcs.openjdk;\n    exports org.openjdk.skara.vcs.openjdk.convert;\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Author.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.util.Objects;\nimport java.util.Optional;\n\npublic class Author {\n    private final String name;\n    private final String email;\n\n    public Author(String name) {\n        this(name, null);\n    }\n\n    public Author(String name, String email) {\n        this.name = name;\n        this.email = email;\n    }\n\n    public String name() {\n        return name;\n    }\n\n    public String email() {\n        return email;\n    }\n\n    public static Author fromString(String s) {\n        var open = s.indexOf('<');\n        var close = s.lastIndexOf('>');\n        if (open < 1 || close != (s.length() - 1)) {\n            return new Author(s);\n        }\n\n        var email = s.substring(open + 1, close);\n        var name = s.substring(0, open).trim();\n        return new Author(name, email);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(name, email);\n    }\n\n    @Override\n    public String toString() {\n        if (email == null) {\n            return name;\n        }\n        return name + \" <\" + email + \">\";\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof Author other)) {\n            return false;\n        }\n\n        return Objects.equals(name, other.name) &&\n               Objects.equals(email, other.email);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/BinaryHunk.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.io.BufferedWriter;\nimport java.io.IOException;\nimport java.util.List;\n\npublic class BinaryHunk {\n    private boolean isLiteral;\n    private final int inflatedSize;\n    private final List<String> data; // base85 encoded with leading size character\n\n    private BinaryHunk(boolean isLiteral, int inflatedSize, List<String> data) {\n        this.isLiteral = isLiteral;\n        this.inflatedSize = inflatedSize;\n        this.data = data;\n    }\n\n    public static BinaryHunk ofLiteral(int inflatedSize, List<String> data) {\n        return new BinaryHunk(true, inflatedSize, data);\n    }\n\n    public static BinaryHunk ofDelta(int inflatedSize, List<String> data) {\n        return new BinaryHunk(false, inflatedSize, data);\n    }\n\n    public int inflatedSize() {\n        return inflatedSize;\n    }\n\n    public List<String> data() {\n        return data;\n    }\n\n    public boolean isLiteral() {\n        return isLiteral;\n    }\n\n    public boolean isDelta() {\n        return !isLiteral;\n    }\n\n    public void write(BufferedWriter w) throws IOException {\n        if (isLiteral()) {\n            w.append(\"literal \");\n        } else {\n            w.append(\"delta \");\n        }\n        w.append(Integer.toString(inflatedSize));\n        w.newLine();\n\n        for (var line : data) {\n            w.append(line);\n            w.newLine();\n        }\n\n        w.newLine();\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/BinaryPatch.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.nio.file.Path;\nimport java.util.List;\n\npublic class BinaryPatch extends Patch {\n    private final List<BinaryHunk> hunks;\n\n    public BinaryPatch(Path sourcePath, FileType sourceFileType, Hash sourceHash,\n                       Path targetPath, FileType targetFileType, Hash targetHash,\n                       Status status, List<BinaryHunk> hunks) {\n        super(sourcePath, sourceFileType, sourceHash, targetPath, targetFileType, targetHash, status);\n        this.hunks = hunks;\n    }\n\n    public List<BinaryHunk> hunks() {\n        return hunks;\n    }\n\n    @Override\n    public boolean isEmpty() {\n        return hunks.isEmpty();\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Bookmark.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\npublic class Bookmark {\n    private final String name;\n\n    public Bookmark(String name) {\n        this.name = name;\n    }\n\n    public String name() {\n        return this.name;\n    }\n\n    @Override\n    public String toString() {\n        return name;\n    }\n\n    @Override\n    public int hashCode() {\n        return name.hashCode();\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof Bookmark other)) {\n            return false;\n        }\n\n        return name.equals(other.name);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Branch.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\npublic class Branch {\n    private static final Branch defaultGit = new Branch(\"master\");\n    private static final Branch defaultHg = new Branch(\"default\");\n\n    private final String name;\n\n    public Branch(String name) {\n        this.name = name;\n    }\n\n    public String name() {\n        return this.name;\n    }\n\n    public static Branch defaultFor(VCS vcs) {\n        if (vcs == VCS.GIT) {\n            return defaultGit;\n        }\n        if (vcs == VCS.HG) {\n            return defaultHg;\n        }\n        throw new IllegalArgumentException(\"Unsupported VCS: \" + vcs);\n    }\n\n    @Override\n    public String toString() {\n        return name;\n    }\n\n    @Override\n    public int hashCode() {\n        return name.hashCode();\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof Branch other)) {\n            return false;\n        }\n\n        return name.equals(other.name);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Commit.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.time.*;\nimport java.time.format.*;\nimport java.util.*;\n\npublic class Commit {\n    private final CommitMetadata metadata;\n    private final List<Diff> parentDiffs;\n\n    public Commit(CommitMetadata metadata, List<Diff> parentDiffs) {\n        this.metadata = metadata;\n        this.parentDiffs = parentDiffs;\n    }\n\n    public Hash hash() {\n        return metadata.hash();\n    }\n\n    public Author author() {\n        return metadata.author();\n    }\n\n    public Author committer() {\n        return metadata.committer();\n    }\n\n    public List<String> message() {\n        return metadata.message();\n    }\n\n    public List<Hash> parents() {\n        return metadata.parents();\n    }\n\n    public List<Diff> parentDiffs() {\n        return parentDiffs;\n    }\n\n    public boolean isInitialCommit() {\n        return metadata.isInitialCommit();\n    }\n\n    public ZonedDateTime authored() {\n        return metadata.authored();\n    }\n\n    public ZonedDateTime committed() {\n        return metadata.committed();\n    }\n\n    public boolean isMerge() {\n        return metadata.isMerge();\n    }\n\n    public int numParents() {\n        return metadata.numParents();\n    }\n\n    public CommitMetadata metadata() {\n        return metadata;\n    }\n\n    @Override\n    public String toString() {\n        return metadata.toString();\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(metadata, parentDiffs);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof Commit other)) {\n            return false;\n        }\n\n        return Objects.equals(metadata, other.metadata) && Objects.equals(parentDiffs, other.parentDiffs);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/CommitMetadata.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.util.*;\nimport java.time.*;\nimport java.time.format.*;\n\npublic class CommitMetadata {\n    private final Hash hash;\n    private final List<Hash> parents;\n    private final Author author;\n    private final ZonedDateTime authored;\n    private final Author committer;\n    private final ZonedDateTime committed;\n    private final List<String> message;\n\n    public CommitMetadata(Hash hash, List<Hash> parents,\n                          Author author, ZonedDateTime authored,\n                          Author committer, ZonedDateTime committed,\n                          List<String> message) {\n        this.hash = hash;\n        this.parents = parents;\n        this.author = author;\n        this.authored = authored;\n        this.committer = committer;\n        this.committed = committed;\n        this.message = message;\n    }\n\n    public Hash hash() {\n        return hash;\n    }\n\n    public Author author() {\n        return author;\n    }\n\n    public Author committer() {\n        return committer;\n    }\n\n    public List<String> message() {\n        return message;\n    }\n\n    public List<Hash> parents() {\n        return parents;\n    }\n\n    public ZonedDateTime authored() {\n        return authored;\n    }\n\n    public ZonedDateTime committed() {\n        return committed;\n    }\n\n    public boolean isInitialCommit() {\n        return numParents() == 1 && parents.get(0).equals(Hash.zero());\n    }\n\n    public boolean isMerge() {\n        return parents().size() > 1;\n    }\n\n    public int numParents() {\n        return parents().size();\n    }\n\n    @Override\n    public String toString() {\n        final var formatter = DateTimeFormatter.RFC_1123_DATE_TIME;\n        final var displayDate = authored.format(formatter);\n        return String.format(\"%s  %-12s  %s  %s\",\n                             hash().toString(), author(), displayDate, message.get(0));\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(hash, parents, author, authored, committer, committed, message);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof CommitMetadata other)) {\n            return false;\n        }\n\n        return Objects.equals(hash, other.hash) &&\n               Objects.equals(parents, other.parents) &&\n               Objects.equals(author, other.author) &&\n               Objects.equals(authored, other.authored) &&\n               Objects.equals(committer, other.committer) &&\n               Objects.equals(committed, other.committed) &&\n               Objects.equals(message, other.message);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Commits.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.io.*;\nimport java.util.*;\nimport java.util.function.Consumer;\nimport java.util.stream.*;\n\npublic interface Commits extends Closeable, Iterable<Commit> {\n    default List<Commit> asList() throws IOException {\n        var result = new ArrayList<Commit>();\n        for (var commit : this) {\n            result.add(commit);\n        }\n\n        close();\n\n        return result;\n    }\n\n    default Stream<Commit> stream() {\n        return StreamSupport.stream(spliterator(), false).onClose(() -> {\n            try {\n                close();\n            } catch (IOException e) {\n                throw new UncheckedIOException(e);\n            }\n        });\n    }\n\n    @Override\n    default void forEach(Consumer<? super Commit> action) {\n        Iterable.super.forEach(action);\n        try {\n            close();\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Diff.java",
    "content": "/*\n * Copyright (c) 2018, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.io.IOException;\nimport java.io.Writer;\nimport java.io.BufferedWriter;\nimport java.io.StringWriter;\nimport java.io.UncheckedIOException;\nimport java.nio.file.Path;\nimport java.nio.file.Files;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class Diff {\n    private final Hash from;\n    private final Hash to;\n    private final List<Patch> patches;\n    private final boolean complete;\n\n    public Diff(Hash from, Hash to, List<Patch> patches) {\n        this(from, to, patches, true);\n    }\n\n    public Diff(Hash from, Hash to, List<Patch> patches, boolean complete) {\n        this.from = from;\n        this.to = to;\n        this.patches = patches;\n        this.complete = complete;\n    }\n\n    public Hash from() {\n        return from;\n    }\n\n    public Hash to() {\n        return to;\n    }\n\n    public List<Patch> patches() {\n        return patches;\n    }\n\n    public boolean complete() {\n        return complete;\n    }\n\n    public List<WebrevStats> stats() {\n        return patches().stream()\n                        .filter(Patch::isTextual)\n                        .map(Patch::asTextualPatch)\n                        .map(TextualPatch::stats)\n                        .collect(Collectors.toList());\n    }\n\n    public WebrevStats totalStats() {\n        var added = stats().stream().mapToInt(WebrevStats::added).sum();\n        var removed = stats().stream().mapToInt(WebrevStats::removed).sum();\n        var modified = stats().stream().mapToInt(WebrevStats::modified).sum();\n        return new WebrevStats(added, removed, modified);\n    }\n\n    public void write(BufferedWriter w) throws IOException {\n        for (var patch : patches()) {\n            patch.write(w);\n        }\n    }\n\n    public void toFile(Path p) throws IOException {\n        try (var w = Files.newBufferedWriter(p)) {\n            write(w);\n        }\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/DiffComparator.java",
    "content": "/*\n * Copyright (c) 2020, 2023, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.util.*;\nimport java.util.regex.Pattern;\n\npublic class DiffComparator {\n\n    private static final Pattern COPYRIGHT_PATTERN = Pattern.compile(\"\"\"\n            -(.)*Copyright \\\\(c\\\\) (?:\\\\d|\\\\s|,)* Oracle and/or its affiliates\\\\. All rights reserved\\\\.\n            \\\\+(.)*Copyright \\\\(c\\\\) (?:\\\\d|\\\\s|,)* Oracle and/or its affiliates\\\\. All rights reserved\\\\.\n            \"\"\");\n\n    public static boolean areFuzzyEqual(Diff a, Diff b) {\n        var aPatches = new HashMap<String, Patch>();\n        for (var patch : a.patches()) {\n            aPatches.put(patch.toString(), patch);\n        }\n        var bPatches = new HashMap<String, Patch>();\n        for (var patch : b.patches()) {\n            bPatches.put(patch.toString(), patch);\n        }\n\n        if (aPatches.size() != bPatches.size()) {\n            return false;\n        }\n        var onlyInA = new HashSet<>(aPatches.keySet());\n        onlyInA.removeAll(bPatches.keySet());\n        if (!onlyInA.isEmpty()) {\n            return false;\n        }\n        var onlyInB = new HashSet<>(bPatches.keySet());\n        onlyInB.removeAll(aPatches.keySet());\n        if (!onlyInB.isEmpty()) {\n            return false;\n        }\n\n        for (var key : aPatches.keySet()) {\n            var aPatch = aPatches.get(key).asTextualPatch();\n            var bPatch = bPatches.get(key).asTextualPatch();\n            if (!areFuzzyEqual(aPatch, bPatch)) {\n                return false;\n            }\n        }\n\n        return true;\n    }\n\n    private static boolean areFuzzyEqual(Patch a, Patch b) {\n        var aHunks = a.asTextualPatch().hunks()\n                .stream()\n                .filter(hunk -> !COPYRIGHT_PATTERN.matcher(hunk.toString()).find())\n                .toList();\n        var bHunks = b.asTextualPatch().hunks()\n                .stream()\n                .filter(hunk -> !COPYRIGHT_PATTERN.matcher(hunk.toString()).find())\n                .toList();\n\n        if (aHunks.size() != bHunks.size()) {\n            return false;\n        }\n        for (var i = 0; i < aHunks.size(); i++) {\n            var aHunk = aHunks.get(i);\n            var bHunk = bHunks.get(i);\n\n            if (aHunk.source().lines().size() != bHunk.source().lines().size()) {\n                return false;\n            }\n            for (var j = 0; j < aHunk.source().lines().size(); j++) {\n                var aLine = aHunk.source().lines().get(j);\n                var bLine = bHunk.source().lines().get(j);\n                if (!aLine.equals(bLine)) {\n                    return false;\n                }\n            }\n\n            if (aHunk.target().lines().size() != bHunk.target().lines().size()) {\n                return false;\n            }\n            for (var j = 0; j < aHunk.target().lines().size(); j++) {\n                var aLine = aHunk.target().lines().get(j);\n                var bLine = bHunk.target().lines().get(j);\n                if (!aLine.equals(bLine)) {\n                    return false;\n                }\n            }\n        }\n        return true;\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/FileEntry.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.util.Objects;\nimport java.nio.file.Path;\n\npublic class FileEntry {\n    private final Hash commit;\n    private final FileType type;\n    private final Hash hash;\n    private final Path path;\n\n    public FileEntry(Hash commit, FileType type, Hash hash, Path path) {\n        this.commit = commit;\n        this.type = type;\n        this.hash = hash;\n        this.path = path;\n    }\n\n    public Hash commit() {\n        return commit;\n    }\n\n    public FileType type() {\n        return type;\n    }\n\n    public Hash hash() {\n        return hash;\n    }\n\n    public Path path() {\n        return path;\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(commit, type, hash, path);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof FileEntry e)) {\n            return false;\n        }\n\n        return Objects.equals(commit, e.commit) &&\n               Objects.equals(type, e.type) &&\n               Objects.equals(hash, e.hash) &&\n               Objects.equals(path, e.path);\n    }\n\n    @Override\n    public String toString() {\n        return type.toString() + \" blob \" + hash.toString() + \"\\t\" + path.toString();\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/FileType.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.nio.file.attribute.PosixFilePermission;\nimport java.nio.file.attribute.PosixFilePermissions;\nimport java.util.Optional;\nimport java.util.Set;\n\npublic class FileType {\n    private enum Type {\n        DIRECTORY,\n        REGULAR_NON_EXECUTABLE,\n        REGULAR_NON_EXECUTABLE_GROUP_WRITABLE,\n        REGULAR_EXECUTABLE,\n        SYMBOLIC_LINK,\n        VCS_LINK\n    }\n\n    private final Type type;\n\n    private FileType(Type type) {\n        this.type = type;\n    }\n\n    public static FileType fromOctal(String s) {\n        switch (s) {\n            case \"040000\":\n                return new FileType(Type.DIRECTORY);\n            case \"100644\":\n                return new FileType(Type.REGULAR_NON_EXECUTABLE);\n            case \"100664\":\n                return new FileType(Type.REGULAR_NON_EXECUTABLE_GROUP_WRITABLE);\n            case \"100755\":\n                return new FileType(Type.REGULAR_EXECUTABLE);\n            case \"120000\":\n                return new FileType(Type.SYMBOLIC_LINK);\n            case \"160000\":\n                return new FileType(Type.VCS_LINK);\n            case \"000000\":\n                return null;\n            default:\n                throw new IllegalArgumentException(\"Unexpected octal file mode: \" + s);\n        }\n    }\n\n    public String toOctal() {\n        switch (type) {\n            case DIRECTORY:\n                return \"040000\";\n            case REGULAR_NON_EXECUTABLE:\n                return \"100644\";\n            case REGULAR_NON_EXECUTABLE_GROUP_WRITABLE:\n                return \"100664\";\n            case REGULAR_EXECUTABLE:\n                return \"100755\";\n            case SYMBOLIC_LINK:\n                return \"120000\";\n            case VCS_LINK:\n                return \"160000\";\n            default:\n                throw new IllegalStateException(\"Unexpected type: \" + type);\n        }\n    }\n\n    public boolean isDirectory() {\n        return type == Type.DIRECTORY;\n    }\n\n    public boolean isRegularNonExecutable() {\n        return type == Type.REGULAR_NON_EXECUTABLE;\n    }\n\n    public boolean isRegular() {\n        return type == Type.REGULAR_EXECUTABLE ||\n               type == Type.REGULAR_NON_EXECUTABLE ||\n               type == Type.REGULAR_NON_EXECUTABLE_GROUP_WRITABLE;\n    }\n\n    public boolean isGroupWritable() {\n        return type == Type.REGULAR_NON_EXECUTABLE_GROUP_WRITABLE;\n    }\n\n    public boolean isExecutable() {\n        return type == Type.REGULAR_EXECUTABLE;\n    }\n\n    public boolean isSymbolicLink() {\n        return type == Type.SYMBOLIC_LINK;\n    }\n\n    public boolean isVCSLink() {\n        return type == Type.VCS_LINK;\n    }\n\n    public boolean isLink() {\n        return isSymbolicLink() || isVCSLink();\n    }\n\n    public Optional<Set<PosixFilePermission>> permissions() {\n        switch (type) {\n            case REGULAR_NON_EXECUTABLE:\n                return Optional.of(PosixFilePermissions.fromString(\"rw-r--r--\"));\n            case REGULAR_NON_EXECUTABLE_GROUP_WRITABLE:\n                return Optional.of(PosixFilePermissions.fromString(\"rw-rw-r--\"));\n            case REGULAR_EXECUTABLE:\n                return Optional.of(PosixFilePermissions.fromString(\"rwxr-xr-x\"));\n            default:\n                return Optional.empty();\n        }\n    }\n\n    @Override\n    public String toString() {\n        return toOctal();\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof FileType ft)) {\n            return false;\n        }\n\n        return type == ft.type;\n    }\n\n    @Override\n    public int hashCode() {\n        return type.hashCode();\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Hash.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\npublic class Hash {\n    private static final Hash ZERO = new Hash(\"0\".repeat(40));\n    private final String hex;\n\n    public Hash(String hex) {\n        this.hex = hex;\n    }\n\n    public static Hash zero() {\n        return ZERO;\n    }\n\n    @Override\n    public int hashCode() {\n        return hex.hashCode();\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof Hash h)) {\n            return false;\n        }\n\n        if (o == this) {\n            return true;\n        }\n\n        return hex.equals(h.hex());\n    }\n\n    @Override\n    public String toString() {\n        return hex();\n    }\n\n    public String hex() {\n        return hex;\n    }\n\n    public String abbreviate() {\n        return hex().substring(0, 8);\n    }\n\n    public boolean isValid() {\n        return hex.toLowerCase().matches(\"[a-f0-9]{40}\");\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Hunk.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.io.BufferedWriter;\nimport java.io.StringWriter;\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.util.List;\n\npublic class Hunk {\n    public static final class Info {\n        private final Range range;\n        private final List<String> lines;\n        private final boolean hasNewlineAtEndOfFile;\n\n        private Info(Range range, List<String> lines, boolean hasNewlineAtEndOfFile) {\n            this.range = range;\n            this.lines = lines;\n            this.hasNewlineAtEndOfFile = hasNewlineAtEndOfFile;\n        }\n\n        public Range range() {\n            return range;\n        }\n\n        public List<String> lines() {\n            return lines;\n        }\n\n        public boolean hasNewlineAtEndOfFile() {\n            return hasNewlineAtEndOfFile;\n        }\n    }\n\n    private final Info source;\n    private final Info target;\n\n    public Hunk(Range sourceRange, List<String> sourceLines,\n                Range targetRange, List<String> targetLines) {\n        this(sourceRange, sourceLines, true, targetRange, targetLines, true);\n    }\n\n    public Hunk(Range sourceRange, List<String> sourceLines, boolean sourceHasNewlineAtEndOfFile,\n                Range targetRange, List<String> targetLines, boolean targetHasNewlineAtEndOfFile) {\n        this.source = new Info(sourceRange, sourceLines, sourceHasNewlineAtEndOfFile);\n        this.target = new Info(targetRange, targetLines, targetHasNewlineAtEndOfFile);\n    }\n\n    public Info source() {\n        return source;\n    }\n\n    public Info target() {\n        return target;\n    }\n\n    public WebrevStats stats() {\n        var modified = Math.min(source.lines().size(), target.lines().size());\n        var added = target.lines().size() - modified;\n        var removed = source.lines().size() - modified;\n        return new WebrevStats(added, removed, modified);\n    }\n\n    public int changes() {\n        return source.lines().size() + target.lines().size();\n    }\n\n    public int additions() {\n        return target.lines().size();\n    }\n\n    public int deletions() {\n        return source.lines().size();\n    }\n\n    public void write(BufferedWriter w) throws IOException {\n        w.append(\"@@ -\");\n        w.append(source.range().toString());\n        w.append(\" +\");\n        w.append(target.range().toString());\n        w.append(\" @@\");\n        w.write(\"\\n\");\n\n        for (var line : source.lines()) {\n            w.append(\"-\");\n            w.append(line);\n            w.write(\"\\n\");\n        }\n        if (!source.hasNewlineAtEndOfFile()) {\n            w.append(\"\\\\ No newline at end of file\");\n            w.write(\"\\n\");\n        }\n\n        for (var line : target.lines()) {\n            w.append(\"+\");\n            w.append(line);\n            w.write(\"\\n\");\n        }\n        if (!target.hasNewlineAtEndOfFile()) {\n            w.append(\"\\\\ No newline at end of file\");\n            w.write(\"\\n\");\n        }\n    }\n\n    @Override\n    public String toString() {\n        var sb = new StringBuilder();\n        sb.append(\"@@ -\");\n        sb.append(source.range().toString());\n        sb.append(\" +\");\n        sb.append(target.range().toString());\n        sb.append(\" @@\");\n        sb.append(\"\\n\");\n\n        for (var line : source.lines()) {\n            sb.append(\"-\");\n            sb.append(line);\n            sb.append(\"\\n\");\n        }\n        if (!source.hasNewlineAtEndOfFile()) {\n            sb.append(\"\\\\ No newline at end of file\");\n            sb.append(\"\\n\");\n        }\n\n        for (var line : target.lines()) {\n            sb.append(\"+\");\n            sb.append(line);\n            sb.append(\"\\n\");\n        }\n        if (!target.hasNewlineAtEndOfFile()) {\n            sb.append(\"\\\\ No newline at end of file\");\n            sb.append(\"\\n\");\n        }\n        return sb.toString();\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Patch.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.io.BufferedWriter;\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.nio.file.Files;\nimport java.util.Optional;\n\npublic abstract class Patch {\n    public static final class Info {\n        private final Path path;\n        private final FileType type;\n        private final Hash hash;\n\n        private Info(Path path, FileType type, Hash hash) {\n            this.path = path;\n            this.type = type;\n            this.hash = hash;\n        }\n\n        public Optional<Path> path() {\n            return Optional.ofNullable(path);\n        }\n\n        public Optional<FileType> type() {\n            return Optional.ofNullable(type);\n        }\n\n        public Hash hash() {\n            return hash;\n        }\n    }\n\n    private final Info source;\n    private final Info target;\n\n    private final Status status;\n\n    public Patch(Path sourcePath, FileType sourceFileType, Hash sourceHash,\n                 Path targetPath, FileType targetFileType, Hash targetHash,\n                 Status status) {\n        this.source = new Info(sourcePath, sourceFileType, sourceHash);\n        this.target = new Info(targetPath, targetFileType, targetHash);\n        this.status = status;\n    }\n\n    public Info source() {\n        return source;\n    }\n\n    public Info target() {\n        return target;\n    }\n\n    public Status status() {\n        return status;\n    }\n\n    public abstract boolean isEmpty();\n\n    public boolean isBinary() {\n        return this instanceof BinaryPatch;\n    }\n\n    public boolean isTextual() {\n        return this instanceof TextualPatch;\n    }\n\n    public TextualPatch asTextualPatch() {\n        if (isTextual()) {\n            return (TextualPatch) this;\n        }\n        throw new IllegalStateException(\"Cannot convert binary patch to textual\");\n    }\n\n    public BinaryPatch asBinaryPatch() {\n        if (isBinary()) {\n            return (BinaryPatch) this;\n        }\n        throw new IllegalStateException(\"Cannot convert textual patch to binary\");\n    }\n\n    public void write(BufferedWriter w) throws IOException {\n        // header\n        var sourcePath = pathWithUnixSeps(source.path().isPresent() ?\n            source.path().get() : target.path().get());\n        var targetPath = pathWithUnixSeps(target.path().isPresent() ?\n            target.path().get() : source.path().get());\n\n        w.append(\"diff --git \");\n        w.append(\"a/\" + sourcePath);\n        w.append(\" \");\n        w.append(\"b/\" + targetPath);\n        w.write(\"\\n\");\n\n        // extended headers\n        if (status.isModified()) {\n            if (!source.type().get().equals(target.type().get())) {\n                w.append(\"old mode \");\n                w.append(source.type().get().toOctal());\n                w.write(\"\\n\");\n\n                w.append(\"new mode \");\n                w.append(target.type().get().toOctal());\n                w.write(\"\\n\");\n            }\n            w.append(\"index \");\n            w.append(source().hash().hex());\n            w.append(\"..\");\n            w.append(target().hash().hex());\n            w.append(\" \");\n            w.append(target.type().get().toOctal());\n            w.write(\"\\n\");\n        } else if (status.isAdded()) {\n            w.append(\"new file mode \");\n            w.append(target.type().get().toOctal());\n            w.write(\"\\n\");\n\n            w.append(\"index \");\n            w.append(Hash.zero().hex());\n            w.append(\"..\");\n            w.append(target.hash().hex());\n            w.write(\"\\n\");\n        } else if (status.isDeleted()) {\n            w.append(\"deleted file mode \");\n            w.append(source.type().get().toOctal());\n            w.write(\"\\n\");\n\n            w.append(\"index \");\n            w.append(source.hash().hex());\n            w.append(\"..\");\n            w.append(Hash.zero().hex());\n            w.write(\"\\n\");\n        } else if (status.isCopied()) {\n            w.append(\"similarity index \");\n            w.append(Integer.toString(status.score()));\n            w.append(\"%\");\n            w.write(\"\\n\");\n\n            w.append(\"copy from \");\n            w.append(source.path().get().toString());\n            w.write(\"\\n\");\n            w.append(\"copy to \");\n            w.append(target.path().get().toString());\n            w.write(\"\\n\");\n\n            w.append(\"index \");\n            w.append(source().hash().hex());\n            w.append(\"..\");\n            w.append(target().hash().hex());\n            w.append(\" \");\n            w.append(target.type().get().toOctal());\n            w.write(\"\\n\");\n        } else if (status.isRenamed()) {\n            w.append(\"similarity index \");\n            w.append(Integer.toString(status.score()));\n            w.append(\"%\");\n            w.write(\"\\n\");\n\n            w.append(\"rename from \");\n            w.append(source.path().get().toString());\n            w.write(\"\\n\");\n            w.append(\"rename to \");\n            w.append(target.path().get().toString());\n            w.write(\"\\n\");\n\n            w.append(\"index \");\n            w.append(source().hash().hex());\n            w.append(\"..\");\n            w.append(target().hash().hex());\n            w.append(\" \");\n            w.append(target.type().get().toOctal());\n            w.write(\"\\n\");\n        }\n\n        w.append(\"--- \");\n        w.append(source.path().isPresent() ? \"a/\" + sourcePath : \"/dev/null\");\n        w.append(\"\\n\");\n        w.append(\"+++ \");\n        w.append(target.path().isPresent() ? \"b/\" + targetPath : \"/dev/null\");\n        w.write(\"\\n\");\n\n        if (isBinary()) {\n            w.append(\"GIT binary patch\");\n            w.write(\"\\n\");\n            for (var hunk : asBinaryPatch().hunks()) {\n                hunk.write(w);\n            }\n        } else {\n            for (var hunk : asTextualPatch().hunks()) {\n                hunk.write(w);\n            }\n        }\n    }\n\n    public void toFile(Path p) throws IOException {\n        try (var w = Files.newBufferedWriter(p)) {\n            write(w);\n        }\n    }\n\n    public static String pathWithUnixSeps(Path p) {\n        return p.toString().replace('\\\\', '/');\n    }\n\n    @Override\n    public String toString() {\n        var desc = \"\";\n        if (status.isRenamed() || status.isCopied()) {\n            desc = source.path().get().toString() + \" -> \" + target.path().get().toString();\n        } else if (status.isModified() || status.isDeleted()) {\n            desc = source.path().get().toString();\n        } else if (status.isAdded() || status.isUnmerged()) {\n            desc = target.path().get().toString();\n        } else {\n            throw new IllegalStateException(\"Unexpected status: \" + status);\n        }\n        return status.toString() + \" \" + desc;\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Range.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.util.Objects;\n\npublic class Range {\n    private final int start;\n    private final int count;\n\n    public Range(int start, int count) {\n        this.start = start;\n        this.count = count;\n    }\n\n    public static Range fromString(String s) {\n        var separatorIndex = s.indexOf(\",\");\n\n        if (separatorIndex == -1) {\n            var start = Integer.parseInt(s);\n            return new Range(start, 1);\n        }\n\n        var start = Integer.parseInt(s.substring(0, separatorIndex));\n\n        // Need to work around a bug in git where git sometimes print -1\n        // as an unsigned int for the count part of the range\n        var countString = s.substring(separatorIndex + 1, s.length());\n        var count =\n            countString.equals(\"18446744073709551615\") ?  0 : Integer.parseInt(countString);\n\n        if (count == 0 && start != 0) {\n            // start is off-by-one when count is 0.\n            // but if start == 0, a file was added and we need a 0 here.\n            start++;\n        }\n\n        return new Range(start, count);\n    }\n\n    public static Range fromCombinedString(String s) {\n        var separatorIndex = s.indexOf(\",\");\n\n        if (separatorIndex == -1) {\n            var start = Integer.parseInt(s);\n            return new Range(start, 1);\n        }\n\n        var start = Integer.parseInt(s.substring(0, separatorIndex));\n\n        // Need to work around a bug in git where git sometimes print -1\n        // as an unsigned int for the count part of the range\n        var countString = s.substring(separatorIndex + 1, s.length());\n        var count =\n            countString.equals(\"18446744073709551615\") ?  0 : Integer.parseInt(countString);\n\n        return new Range(start, count);\n    }\n\n    public int start() {\n        return this.start;\n    }\n\n    public int count() {\n        return this.count;\n    }\n\n    public int end() {\n        return start + count;\n    }\n\n    @Override\n    public String toString() {\n        return Integer.toString(start) + \",\" + Integer.toString(count);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(start, count);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof Range other)) {\n            return false;\n        }\n\n        return start == other.start && count == other.count;\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/ReadOnlyRepository.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.nio.charset.StandardCharsets;\nimport java.util.stream.Collectors;\nimport java.util.*;\n\npublic interface ReadOnlyRepository {\n    Hash head() throws IOException;\n    Optional<Branch> currentBranch() throws IOException;\n    Optional<Bookmark> currentBookmark() throws IOException;\n    Branch defaultBranch() throws IOException;\n    List<Branch> branches() throws IOException;\n    List<Branch> branches(String remote) throws IOException;\n    Optional<Tag> defaultTag() throws IOException;\n    List<Tag> tags() throws IOException;\n    Commits commits() throws IOException;\n    Commits commits(int n) throws IOException;\n    Commits commits(boolean reverse) throws IOException;\n    Commits commits(int n, boolean reverse) throws IOException;\n    Commits commits(String range) throws IOException;\n    Commits commits(String range, boolean reverse) throws IOException;\n    Commits commits(String range, int n) throws IOException;\n    Commits commits(String range, int n, boolean reverse) throws IOException;\n    Commits commits(List<Hash> reachableFrom, List<Hash> unreachableFrom) throws IOException;\n    Optional<Commit> lookup(Hash h) throws IOException;\n    Optional<Commit> lookup(Branch b) throws IOException;\n    Optional<Commit> lookup(Tag t) throws IOException;\n    List<CommitMetadata> commitMetadata() throws IOException;\n    default Optional<CommitMetadata> commitMetadata(Hash hash) throws IOException {\n        var l = commitMetadata(range(hash));\n        if (l.size() > 1) {\n            throw new IllegalStateException(\"More than one commit for hash: \" + hash.hex());\n        }\n        if (l.size() == 0) {\n            return Optional.empty();\n        }\n        return Optional.of(l.get(0));\n    }\n    List<CommitMetadata> commitMetadata(boolean reverse) throws IOException;\n    List<CommitMetadata> commitMetadata(String range) throws IOException;\n    List<CommitMetadata> commitMetadata(Hash from, Hash to) throws IOException;\n    List<CommitMetadata> commitMetadata(String range, boolean reverse) throws IOException;\n    List<CommitMetadata> commitMetadata(Hash from, Hash to, boolean reverse) throws IOException;\n    List<CommitMetadata> commitMetadata(List<Path> paths) throws IOException;\n    List<CommitMetadata> commitMetadata(List<Path> paths, boolean reverse) throws IOException;\n    List<CommitMetadata> commitMetadata(String range, List<Path> paths) throws IOException;\n    List<CommitMetadata> commitMetadata(Hash from, Hash to, List<Path> paths) throws IOException;\n    List<CommitMetadata> commitMetadata(String range, List<Path> paths, boolean reverse) throws IOException;\n    List<CommitMetadata> commitMetadata(Hash from, Hash to, List<Path> paths, boolean reverse) throws IOException;\n\n    // Can't overload on both List<Path> and List<Branch>\n    List<CommitMetadata> commitMetadataFor(List<Branch> branches) throws IOException;\n\n    String range(Hash h);\n    String rangeInclusive(Hash from, Hash to);\n    String rangeExclusive(Hash from, Hash to);\n    Path root() throws IOException;\n    boolean exists() throws IOException;\n    boolean isHealthy() throws IOException;\n    boolean isEmpty() throws IOException;\n    boolean isClean() throws IOException;\n\n    /**\n     * Finds the hash of the merge-base commit for the two given hashes. Returns\n     * empty if the two hashes do not share any history.\n     */\n    default Optional<Hash> mergeBaseOptional(Hash first, Hash second) throws IOException {\n        return Optional.of(mergeBase(first, second));\n    }\n\n    /**\n     * Finds the hash of the merge base commit for the two given hashes. Throws\n     * IOException if it can't be found.\n     */\n    Hash mergeBase(Hash first, Hash second) throws IOException;\n    boolean isAncestor(Hash ancestor, Hash descendant) throws IOException;\n    Optional<Hash> resolve(String ref) throws IOException;\n    default Optional<Hash> resolve(Tag t) throws IOException {\n        return resolve(t.name());\n    }\n    default Optional<Hash> resolve(Branch b) throws IOException {\n        return resolve(b.name());\n    }\n    boolean contains(Branch b, Hash h) throws IOException;\n    boolean contains(Hash h) throws IOException;\n    Optional<String> username() throws IOException;\n    Optional<byte[]> show(Path p, Hash h) throws IOException;\n    default Optional<List<String>> lines(Path p, Hash h) throws IOException {\n        return show(p, h).map(bytes -> new String(bytes, StandardCharsets.UTF_8).lines().collect(Collectors.toList()));\n    }\n\n    List<FileEntry> files(Hash h, List<Path> paths) throws IOException;\n    default List<FileEntry> files(Hash h, Path... paths) throws IOException {\n        return files(h, Arrays.asList(paths));\n    }\n\n    void dump(FileEntry entry, Path to) throws IOException;\n    List<StatusEntry> status(Hash from, Hash to) throws IOException;\n    List<StatusEntry> status() throws IOException;\n\n    static final int DEFAULT_SIMILARITY = 90;\n    default Diff diff(Hash base, Hash head) throws IOException {\n        return diff(base, head, DEFAULT_SIMILARITY);\n    }\n    Diff diff(Hash base, Hash head, int similarity) throws IOException;\n    default Diff diff(Hash base, Hash head, List<Path> files) throws IOException {\n        return diff(base, head, files, DEFAULT_SIMILARITY);\n    }\n    Diff diff(Hash base, Hash head, List<Path> files, int similarity) throws IOException;\n    default Diff diff(Hash head) throws IOException {\n        return diff(head, DEFAULT_SIMILARITY);\n    }\n    Diff diff(Hash head, int similarity) throws IOException;\n    default Diff diff(Hash head, List<Path> files) throws IOException {\n        return diff(head, files, DEFAULT_SIMILARITY);\n    }\n\n    List<CommitMetadata> follow(Path path) throws IOException;\n    List<CommitMetadata> follow(Path path, Hash base, Hash head) throws IOException;\n    Diff diff(Hash head, List<Path> files, int similarity) throws IOException;\n    List<String> config(String key) throws IOException;\n    Repository copyTo(Path destination) throws IOException;\n    String pullPath(String remote) throws IOException;\n    String pushPath(String remote) throws IOException;\n    boolean isValidRevisionRange(String expression) throws IOException;\n    Optional<String> upstreamFor(Branch branch) throws IOException;\n    List<Reference> remoteBranches(String remote) throws IOException;\n    List<String> remotes() throws IOException;\n    List<Submodule> submodules() throws IOException;\n    Tree tree(Hash h) throws IOException;\n    default Tree tree(Commit c) throws IOException {\n        return tree(c.hash());\n    }\n    default Tree tree(CommitMetadata c) throws IOException {\n        return tree(c.hash());\n    }\n\n    static Optional<ReadOnlyRepository> get(Path p) throws IOException {\n        return Repository.get(p).map(r -> r);\n    }\n\n    static boolean exists(Path p) throws IOException {\n        return Repository.exists(p);\n    }\n\n    Optional<Tag.Annotated> annotate(Tag tag) throws IOException;\n\n    int commitCount() throws IOException;\n\n    int commitCount(List<Branch> branches) throws IOException;\n\n    /**\n     * Returns the special hash that references the virtual commit before the first real commit in a repository.\n     */\n    Hash initialHash();\n\n    Optional<List<String>> stagedFileContents(Path p);\n\n    Commit staged() throws IOException;\n\n    Commit workingTree() throws IOException;\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Reference.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.util.Objects;\n\npublic class Reference {\n    private final String name;\n    private final Hash hash;\n\n    public Reference(String name, Hash hash) {\n        this.name = name;\n        this.hash = hash;\n    }\n\n    public String name() {\n        return name;\n    }\n\n    public Hash hash() {\n        return hash;\n    }\n\n    @Override\n    public String toString() {\n        return name + \": \" + hash.hex();\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof Reference r)) {\n            return false;\n        }\n\n        return Objects.equals(name, r.name) && Objects.equals(hash, r.hash);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(name, hash);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Repository.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport org.openjdk.skara.vcs.git.GitRepository;\nimport org.openjdk.skara.vcs.hg.HgRepository;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.file.Path;\nimport java.time.ZonedDateTime;\nimport java.util.*;\n\npublic interface Repository extends ReadOnlyRepository {\n    Repository init() throws IOException;\n    void checkout(Hash h, boolean force) throws IOException;\n    default void checkout(Hash h) throws IOException {\n        checkout(h, false);\n    }\n    void checkout(Branch b, boolean force) throws IOException;\n    default void checkout(Branch b) throws IOException {\n        checkout(b, false);\n    }\n    default Optional<Hash> fetch(URI uri, String refspec) throws IOException {\n        return fetch(uri, refspec, true);\n    }\n    default Optional<Hash> fetch(URI uri, String refspec, boolean includeTags) throws IOException {\n        return fetch(uri, refspec, includeTags, false);\n    }\n    Optional<Hash> fetch(URI uri, String refspec, boolean includeTags, boolean forceUpdateTags) throws IOException;\n    default void fetchAll(URI uri) throws IOException {\n        fetchAll(uri, true);\n    }\n    void fetchAll(URI uri, boolean includeTags) throws IOException;\n    default void fetchAllRemotes() throws IOException {\n        fetchAllRemotes(false);\n    }\n    void fetchAllRemotes(boolean includeTags) throws IOException;\n    void fetchRemote(String remote) throws IOException;\n    void pushAll(URI uri, boolean force) throws IOException;\n    default void pushAll(URI uri) throws IOException {\n        pushAll(uri, false);\n    }\n    void pushTags(URI uri, boolean force) throws IOException;\n    default void pushTags(URI uri) throws IOException {\n        pushTags(uri, false);\n    }\n    default void push(Hash hash, URI uri, String ref, boolean force) throws IOException {\n        push(hash, uri, ref, force, false);\n    }\n    void push(Hash hash, URI uri, String ref, boolean force, boolean includeTags) throws IOException;\n    void push(Branch branch, String remote, boolean setUpstream) throws IOException;\n    void push(Tag tag, URI uri, boolean force) throws IOException;\n    void push(String refspec, URI uri) throws IOException;\n    void clean() throws IOException;\n    void reset(Hash target, boolean hard) throws IOException;\n    void revert(Hash parent) throws IOException;\n    Repository reinitialize() throws IOException;\n    void squash(Hash h) throws IOException;\n    void add(List<Path> files) throws IOException;\n    default void add(Path... files) throws IOException {\n        add(Arrays.asList(files));\n    }\n    void remove(List<Path> files) throws IOException;\n    default void remove(Path... files) throws IOException {\n        remove(Arrays.asList(files));\n    }\n\n    void pull(boolean includeTags) throws IOException;\n    default void pull() throws IOException {\n        pull(false);\n    }\n\n    void pull(String remote, boolean includeTags) throws IOException;\n    default void pull(String remote) throws IOException {\n        pull(remote, false);\n    }\n\n    void pull(String remote, String refspec, boolean includeTags) throws IOException;\n    default void pull(String remote, String refspec) throws IOException {\n        pull(remote, refspec, false);\n    }\n\n    void addremove() throws IOException;\n    void config(String section, String key, String value, boolean global) throws IOException;\n    default void config(String section, String key, String value) throws IOException {\n        config(section, key, value, false);\n    }\n    Hash commit(String message,\n                String authorName,\n                String authorEmail) throws IOException;\n    Hash commit(String message,\n                String authorName,\n                String authorEmail,\n                ZonedDateTime date) throws IOException;\n    Hash commit(String message,\n                String authorName,\n                String authorEmail,\n                String committerName,\n                String committerEmail) throws IOException;\n    Hash commit(String message,\n                String authorName,\n                String authorEmail,\n                ZonedDateTime authorDate,\n                String committerName,\n                String committerEmail,\n                ZonedDateTime committerDate) throws IOException;\n    Hash commit(String message,\n                String authorName,\n                String authorEmail,\n                ZonedDateTime authorDate,\n                String committerName,\n                String committerEmail,\n                ZonedDateTime committerDate,\n                List<Hash> parents,\n                Tree tree) throws IOException;\n    Hash amend(String message) throws IOException;\n    Hash amend(String message,\n               String authorName,\n               String authorEmail) throws IOException;\n    Hash amend(String message,\n               String authorName,\n               String authorEmail,\n               String committerName,\n               String committerEmail) throws IOException;\n    default Tag tag(Hash hash, String tagName, String message, String authorName, String authorEmail) throws IOException {\n        return tag(hash, tagName, message, authorName, authorEmail, null);\n    }\n    default Tag tag(Hash hash, String tagName, String message, String authorName, String authorEmail, ZonedDateTime date) throws IOException {\n        return tag(hash, tagName, message, authorName, authorEmail, date, false);\n    }\n    Tag tag(Hash hash, String tagName, String message, String authorName, String authorEmail, ZonedDateTime date, boolean force) throws IOException;\n    Branch branch(Hash hash, String branchName) throws IOException;\n    void prune(Branch branch, String remote) throws IOException;\n    void delete(Branch b) throws IOException;\n    void rebase(Hash hash, String committerName, String committerEmail) throws IOException;\n\n    enum FastForward {\n        ONLY,\n        DISABLE,\n        AUTO\n    }\n\n    default void merge(Hash hash) throws IOException {\n        merge(hash, FastForward.AUTO);\n    }\n    void merge(Hash hash, FastForward ff) throws IOException;\n    default void merge(Branch branch) throws IOException {\n        merge(branch, FastForward.AUTO);\n    }\n    void merge(Branch branch, FastForward ff) throws IOException;\n    default void merge(Hash hash, String strategy) throws IOException {\n        merge(hash, strategy, FastForward.AUTO);\n    }\n    void merge(Hash hash, String strategy, FastForward ff) throws IOException;\n    void abortMerge() throws IOException;\n    void addRemote(String name, String path) throws IOException;\n    void setPaths(String remote, String pullPath, String pushPath) throws IOException;\n    void apply(Diff diff, boolean force) throws IOException;\n    void apply(Path patchFile, boolean force)  throws IOException;\n    boolean cherryPick(Hash hash) throws IOException;\n    void copy(Path from, Path to) throws IOException;\n    void move(Path from, Path to) throws IOException;\n    default void setPaths(String remote, String pullPath) throws IOException {\n        setPaths(remote, pullPath, null);\n    }\n    void addSubmodule(String pullPath, Path path) throws IOException;\n    void updateSubmodule(Path path) throws IOException;\n    void deleteUntrackedFiles() throws IOException;\n    default void updateSubmodule(Submodule s) throws IOException {\n        updateSubmodule(s.path());\n    }\n\n    void addNote(Hash hash,\n                 List<String> lines,\n                 String authorName,\n                 String authorEmail,\n                 String committerName,\n                 String committerEmail) throws IOException;\n    default void addNote(Hash hash, List<String> lines, String authorName, String authorEmail) throws IOException {\n        addNote(hash, lines, authorName, authorEmail, authorName, authorEmail);\n    }\n    List<String> notes(Hash hash) throws IOException;\n    void pushNotes(URI uri) throws IOException;\n\n    /**\n     * Check whether this commit is empty.\n     * For a merge commit, it will be considered as empty if it has no merge resolutions.\n     */\n    boolean isEmptyCommit(Hash hash);\n\n    default void push(Hash hash, URI uri, String ref) throws IOException {\n        push(hash, uri, ref, false);\n    }\n\n    default ReadOnlyRepository readOnly() {\n        return this;\n    }\n\n    static Repository init(Path p, VCS vcs) throws IOException {\n        switch (vcs) {\n            case GIT:\n                return new GitRepository(p).init();\n            case HG:\n                return new HgRepository(p).init();\n            default:\n                throw new IllegalArgumentException(\"Invalid enum value: \" + vcs);\n        }\n    }\n\n    /**\n     * Turn on a static flag of all repository providers to ignore local configuration.\n     */\n    static void ignoreConfiguration() {\n        GitRepository.ignoreConfiguration();\n        HgRepository.ignoreConfiguration();\n    }\n\n    boolean isRemergeDiffEmpty(Hash mergeCommitHash) throws IOException;\n\n    static Optional<Repository> get(Path p) throws IOException {\n        var gitRepo = GitRepository.get(p);\n        var hgRepo = HgRepository.get(p);\n        if (gitRepo.isPresent() && hgRepo.isEmpty()) {\n            return gitRepo;\n        } else if (gitRepo.isEmpty() && hgRepo.isPresent()) {\n            return hgRepo;\n        } else if (gitRepo.isPresent() && hgRepo.isPresent()) {\n            // Nested repositories\n            var gitRoot = gitRepo.get().root();\n            var hgRoot = hgRepo.get().root();\n            if (gitRoot.equals(hgRoot)) {\n                throw new IOException(p.toString() + \" contains both a hg and git repository\");\n            }\n            if (hgRoot.startsWith(gitRoot)) {\n                return hgRepo;\n            } else {\n                return gitRepo;\n            }\n        }\n        return Optional.empty();\n    }\n\n    static boolean exists(Path p) throws IOException {\n        return get(p).isPresent();\n    }\n\n    static Repository materialize(Path p, URI remote, String ref) throws IOException {\n        return materialize(p, remote, ref, true);\n    }\n\n    static Repository materialize(Path p, URI remote, String ref, boolean checkout) throws IOException {\n        var localRepo = remote.getPath().endsWith(\".git\") ?\n            Repository.init(p, VCS.GIT) : Repository.init(p, VCS.HG);\n        if (!localRepo.exists()) {\n            localRepo.init();\n        } else if (!localRepo.isHealthy()) {\n            localRepo.reinitialize();\n        } else {\n            try {\n                localRepo.clean();\n            } catch (IOException e) {\n                localRepo.reinitialize();\n            }\n        }\n\n        var baseHash = localRepo.fetch(remote, ref).orElseThrow();\n\n        if (checkout) {\n            try {\n                localRepo.checkout(baseHash, true);\n            } catch (IOException e) {\n                localRepo.reinitialize();\n                baseHash = localRepo.fetch(remote, ref).orElseThrow();\n                localRepo.checkout(baseHash, true);\n            }\n        }\n\n        return localRepo;\n    }\n\n    static Repository clone(URI from) throws IOException {\n        var to = Path.of(from).getFileName();\n        if (to.toString().endsWith(\".git\")) {\n            to = Path.of(to.toString().replace(\".git\", \"\"));\n        }\n        return clone(from, to);\n    }\n\n    static Repository clone(URI from, Path to) throws IOException {\n        return clone(from, to, false);\n    }\n\n    static Repository clone(URI from, Path to, boolean isBare) throws IOException {\n        return clone(from, to, isBare, null);\n    }\n\n    static Repository clone(URI from, Path to, boolean isBare, Path seed) throws IOException {\n        return from.getPath().endsWith(\".git\") ?\n            GitRepository.clone(from, to, isBare, seed) : HgRepository.clone(from, to, isBare, seed);\n    }\n\n    static Repository mirror(URI from, Path to) throws IOException {\n        return from.getPath().toString().endsWith(\".git\") ?\n            GitRepository.mirror(from, to) :\n            HgRepository.clone(from, to, true, null); // hg does not have concept of \"mirror\"\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Status.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.util.Objects;\n\npublic class Status {\n    private enum Operation {\n        ADDED,\n        DELETED,\n        RENAMED,\n        COPIED,\n        MODIFIED,\n        UNMERGED,\n        FILE_TYPE_CHANGED\n    }\n\n    private Operation op;\n    private int score;\n\n    private Status(Operation op, int score) {\n        this.op = op;\n        this.score = score;\n    }\n\n    public boolean isAdded() {\n        return op == Operation.ADDED;\n    }\n\n    public boolean isDeleted() {\n        return op == Operation.DELETED;\n    }\n\n    public boolean isRenamed() {\n        return op == Operation.RENAMED;\n    }\n\n    public boolean isCopied() {\n        return op == Operation.COPIED;\n    }\n\n    public boolean isModified() {\n        return op == Operation.MODIFIED;\n    }\n\n    public boolean isUnmerged() {\n        return op == Operation.UNMERGED;\n    }\n\n    public boolean isFileTypeChanged() {\n        return op == Operation.FILE_TYPE_CHANGED;\n    }\n\n    public int score() {\n        return score;\n    }\n\n    public static Status from(char c) {\n        if (c == 'A') {\n            return new Status(Operation.ADDED, -1);\n        }\n\n        if (c == 'M') {\n            return new Status(Operation.MODIFIED, -1);\n        }\n\n        if (c == 'D') {\n            return new Status(Operation.DELETED, -1);\n        }\n\n        if (c == 'U') {\n            return new Status(Operation.UNMERGED, -1);\n        }\n\n        if (c == 'R') {\n            return new Status(Operation.RENAMED, -1);\n        }\n\n        if (c == 'C') {\n            return new Status(Operation.COPIED, -1);\n        }\n\n        if (c == 'T') {\n            return new Status(Operation.FILE_TYPE_CHANGED, -1);\n        }\n\n        throw new IllegalArgumentException(\"Invalid status character: \" + c);\n    }\n\n    public static Status from(String s) {\n        if (s == null || s.isEmpty()) {\n            throw new IllegalArgumentException(\"Empty status string\");\n        }\n\n        var c = s.charAt(0);\n        if (c == 'A') {\n            return new Status(Operation.ADDED, -1);\n        }\n        if (c == 'M') {\n            return new Status(Operation.MODIFIED, -1);\n        }\n        if (c == 'D') {\n            return new Status(Operation.DELETED, -1);\n        }\n        if (c == 'U') {\n            return new Status(Operation.UNMERGED, -1);\n        }\n        if (c == 'T') {\n            return new Status(Operation.FILE_TYPE_CHANGED, -1);\n        }\n\n        var score = 0;\n        try {\n            score = Integer.parseInt(s.substring(1));\n        } catch (NumberFormatException e) {\n            throw new IllegalArgumentException(\"Invalid score\", e);\n        }\n\n        if (score < 0 || score > 100) {\n            throw new IllegalArgumentException(\"Score must be between 0 and 100: \" + score);\n        }\n\n        if (c == 'R') {\n            return new Status(Operation.RENAMED, score);\n        }\n        if (c == 'C') {\n            return new Status(Operation.COPIED, score);\n        }\n\n        throw new IllegalArgumentException(\"Invalid status string: \" + s);\n    }\n\n    @Override\n    public String toString() {\n        switch (op) {\n            case ADDED:\n                return \"A\";\n            case DELETED:\n                return \"D\";\n            case MODIFIED:\n                return \"M\";\n            case UNMERGED:\n                return \"U\";\n            case FILE_TYPE_CHANGED:\n                return \"T\";\n            case RENAMED:\n                return \"R\" + score;\n            case COPIED:\n                return \"C\" + score;\n            default:\n                throw new IllegalStateException(\"Invalid operation: \" + op);\n        }\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(op, score);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof Status other)) {\n            return false;\n        }\n\n        return Objects.equals(op, other.op) &&\n               Objects.equals(score, other.score);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/StatusEntry.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport org.openjdk.skara.vcs.tools.PatchHeader;\nimport java.nio.file.Path;\nimport java.util.Objects;\nimport java.util.Optional;\n\npublic class StatusEntry {\n    public static final class Info {\n        private final Path path;\n        private final FileType type;\n        private final Hash hash;\n\n        private Info(Path path, FileType type, Hash hash) {\n            this.path = path;\n            this.type = type;\n            this.hash = hash;\n        }\n\n        private Info(Patch.Info info) {\n            this.path = info.path().orElse(null);\n            this.type = info.type().orElse(null);\n            this.hash = info.hash();\n        }\n\n        public Optional<Path> path() {\n            return Optional.ofNullable(path);\n        }\n\n        public Optional<FileType> type() {\n            return Optional.ofNullable(type);\n        }\n\n        public Hash hash() {\n            return hash;\n        }\n    }\n\n    private final Info source;\n    private final Info target;\n\n    private Status status;\n\n    public StatusEntry(Path sourcePath, FileType sourceFileType, Hash sourceHash,\n                       Path targetPath, FileType targetFileType, Hash targetHash,\n                       Status status) {\n        this.source = new Info(sourcePath, sourceFileType, sourceHash);\n        this.target = new Info(targetPath, targetFileType, targetHash);\n        this.status = status;\n    }\n\n    public StatusEntry(Patch patch) {\n        this.source = new Info(patch.source());\n        this.target = new Info(patch.target());\n        this.status = patch.status();\n    }\n\n    public Info source() {\n        return source;\n    }\n\n    public Info target() {\n        return target;\n    }\n\n    public Status status() {\n        return status;\n    }\n\n    public static StatusEntry fromRawLine(String line) {\n        var h = PatchHeader.fromRawLine(line);\n        return new StatusEntry(h.sourcePath(), h.sourceFileType(), h.sourceHash(),\n                               h.targetPath(), h.targetFileType(), h.targetHash(),\n                               h.status());\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Submodule.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.nio.file.Path;\nimport java.util.Objects;\n\npublic class Submodule {\n    private final Hash hash;\n    private final Path path;\n    private final String pullPath;\n\n    public Submodule(Hash hash, Path path, String pullPath) {\n        this.hash = hash;\n        this.path = path;\n        this.pullPath = pullPath;\n    }\n\n    public Hash hash() {\n        return hash;\n    }\n\n    public Path path() {\n        return path;\n    }\n\n    public String pullPath() {\n        return pullPath;\n    }\n\n    @Override\n    public String toString() {\n        return pullPath + \" \" + hash + \" \" + path;\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(hash, path, pullPath);\n    }\n\n    @Override\n    public boolean equals(Object other) {\n        if (other == this) {\n            return true;\n        }\n\n        if (!(other instanceof Submodule o)) {\n            return false;\n        }\n\n        return Objects.equals(hash, o.hash) &&\n               Objects.equals(path, o.path) &&\n               Objects.equals(pullPath, o.pullPath);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Tag.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.time.ZonedDateTime;\n\npublic class Tag {\n    public static class Annotated {\n        private final String name;\n        private final Hash target;\n        private final Author author;\n        private final ZonedDateTime date;\n        private final String message;\n\n        public Annotated(String name, Hash target, Author author, ZonedDateTime date, String message) {\n            this.name = name;\n            this.target = target;\n            this.author = author;\n            this.date = date;\n            this.message = message;\n        }\n\n        public String name() {\n            return name;\n        }\n\n        public Hash target() {\n            return target;\n        }\n\n        public Author author() {\n            return author;\n        }\n\n        public ZonedDateTime date() {\n            return date;\n        }\n\n        public String message() {\n            return message;\n        }\n\n        @Override\n        public boolean equals(Object other) {\n            if (other == this) {\n                return true;\n            }\n\n            if (!(other instanceof Annotated o)) {\n                return false;\n            }\n\n            return Objects.equals(name, o.name) &&\n                   Objects.equals(target, o.target) &&\n                   Objects.equals(author, o.author) &&\n                   Objects.equals(date, o.date) &&\n                   Objects.equals(message, o.message);\n        }\n\n        @Override\n        public int hashCode() {\n            return Objects.hash(name, target, author, date, message);\n        }\n\n        @Override\n        public String toString() {\n            return name + \" -> \" + target.hex();\n        }\n    }\n\n    private final String name;\n\n    public Tag(String name) {\n        this.name = name;\n    }\n\n    public String name() {\n        return name;\n    }\n\n    @Override\n    public String toString() {\n        return name;\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(name);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof Tag other)) {\n            return false;\n        }\n\n        return Objects.equals(name, other.name);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/TextualPatch.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.nio.file.Path;\nimport java.nio.file.Files;\nimport java.util.List;\nimport java.io.IOException;\nimport java.io.StringWriter;\nimport java.io.UncheckedIOException;\nimport java.io.Writer;\n\npublic class TextualPatch extends Patch {\n    private final List<Hunk> hunks;\n\n    public TextualPatch(Path sourcePath, FileType sourceFileType, Hash sourceHash,\n                 Path targetPath, FileType targetFileType, Hash targetHash,\n                 Status status, List<Hunk> hunks) {\n        super(sourcePath, sourceFileType, sourceHash, targetPath, targetFileType, targetHash, status);\n        this.hunks = hunks;\n    }\n\n    public List<Hunk> hunks() {\n        return hunks;\n    }\n\n    @Override\n    public boolean isEmpty() {\n        return hunks.isEmpty();\n    }\n\n    public WebrevStats stats() {\n        int added = 0;\n        int removed = 0;\n        int modified = 0;\n\n        for (var hunk : hunks()) {\n            var stats = hunk.stats();\n            added += stats.added();\n            removed += stats.removed();\n            modified += stats.modified();\n        }\n\n        return new WebrevStats(added, removed, modified);\n    }\n\n    public int additions() {\n        return hunks.stream().mapToInt(Hunk::additions).sum();\n    }\n\n    public int deletions() {\n        return hunks.stream().mapToInt(Hunk::deletions).sum();\n    }\n\n    public int changes() {\n        return additions() + deletions();\n    }\n\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/Tree.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.util.Objects;\n\npublic class Tree {\n    private final Hash hash;\n\n    public Tree(Hash hash) {\n        this.hash = hash;\n    }\n\n    public Hash hash() {\n        return hash;\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) return true;\n        if (o == null || getClass() != o.getClass()) return false;\n        Tree tree = (Tree) o;\n        return hash.equals(tree.hash);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(hash);\n    }\n\n    @Override\n    public String toString() {\n        return \"Tree{\" +\n                \"hash=\" + hash +\n                '}';\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/UnifiedDiffParser.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport org.openjdk.skara.encoding.Base85;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.nio.charset.StandardCharsets;\nimport java.util.*;\nimport java.util.zip.Inflater;\nimport java.util.zip.DataFormatException;\n\npublic class UnifiedDiffParser {\n    public static List<Hunk> parseSingleFileDiff(String[] lines) {\n        return parseSingleFileDiff(Arrays.asList(lines));\n    }\n\n    public static List<Hunk> parseSingleFileDiff(List<String> lines) {\n        var i = 0;\n        if (lines.get(i).startsWith(\"diff \")) {\n            i++;\n        }\n        var extendedHeaders = List.of(\n            \"old mode \",\n            \"new mode \",\n            \"deleted file mode \",\n            \"new file mode \",\n            \"copy from \",\n            \"copy to \",\n            \"rename from \",\n            \"rename to \",\n            \"similarity index \",\n            \"dissimilarity index \",\n            \"index \"\n        );\n        while (i < lines.size()) {\n            var line = lines.get(i);\n            if (extendedHeaders.stream().noneMatch(h -> line.startsWith(h))) {\n                break;\n            }\n            i++;\n        }\n        if (lines.get(i).startsWith(\"--- \")) {\n            i++;\n        }\n        if (lines.get(i).startsWith(\"+++ \")) {\n            i++;\n        }\n\n        var hunks = new ArrayList<Hunk>();\n        while (i < lines.size()) {\n            if (lines.get(i).startsWith(\"Binary files \") && lines.get(i).endsWith(\" differ\")) {\n                i++;\n                continue;\n            }\n            var words = lines.get(i).split(\"\\\\s\");\n            if (!words[0].startsWith(\"@@\")) {\n                throw new IllegalStateException(\"Unexpected diff line at index \" + i + \": \" + lines.get(i));\n            }\n            var sourceRange = Range.fromString(words[1].substring(1));\n            var targetRange = Range.fromString(words[2].substring(1));\n\n            var nextHeader = i + 1;\n            while (nextHeader < lines.size()) {\n                if (lines.get(nextHeader).startsWith(\"@@\")) {\n                    break;\n                }\n                nextHeader++;\n            }\n\n            var hunkLines = lines.subList(i + 1, nextHeader);\n            if (!hunkLines.isEmpty()) {\n                hunks.addAll(parseSingleFileDiff(sourceRange, targetRange, hunkLines));\n            }\n            i = nextHeader;\n        }\n\n        return hunks;\n    }\n\n    public static List<Hunk> parseSingleFileDiff(Range from, Range to, List<String> hunkLines) {\n        var hunks = new ArrayList<Hunk>();\n\n        var sourceStart = from.start();\n        var targetStart = to.start();\n\n        var sourceLines = new ArrayList<String>();\n        var targetLines = new ArrayList<String>();\n\n        int i = 0;\n        while (i < hunkLines.size() && hunkLines.get(i).startsWith(\" \")) {\n            i++;\n            sourceStart++;\n            targetStart++;\n        }\n\n        var targetHasNewlineAtEndOfFile = true;\n        var sourceHasNewlineAtEndOfFile = true;\n        var previousLineType = \"\";\n        while (i < hunkLines.size()) {\n            var line = hunkLines.get(i);\n            if (line.startsWith(\"-\")) {\n                previousLineType = \"-\";\n                sourceLines.add(line.substring(1));\n                i++;\n                continue;\n            } else if (line.startsWith(\"+\")) {\n                previousLineType = \"+\";\n                targetLines.add(line.substring(1));\n                i++;\n                continue;\n            } else if (line.startsWith(\" \")) {\n                previousLineType = \" \";\n                hunks.add(new Hunk(new Range(sourceStart, sourceLines.size()), sourceLines, sourceHasNewlineAtEndOfFile,\n                                   new Range(targetStart, targetLines.size()), targetLines, targetHasNewlineAtEndOfFile));\n\n                sourceStart += sourceLines.size();\n                targetStart += targetLines.size();\n\n                sourceLines = new ArrayList<>();\n                targetLines = new ArrayList<>();\n\n                targetHasNewlineAtEndOfFile = true;\n                sourceHasNewlineAtEndOfFile = true;\n\n                while (i < hunkLines.size() && hunkLines.get(i).startsWith(\" \")) {\n                    i++;\n                    sourceStart++;\n                    targetStart++;\n                }\n            } else if (line.equals(\"\\\\ No newline at end of file\")) {\n                if (previousLineType.equals(\"+\")) {\n                    targetHasNewlineAtEndOfFile = false;\n                } else if (previousLineType.equals(\"-\")) {\n                    sourceHasNewlineAtEndOfFile = false;\n                }\n                i++;\n            } else if (line.startsWith(\"Binary files\") && line.endsWith(\"differ\")) {\n                i++;\n            } else {\n                throw new IllegalStateException(\"Unexpected diff line: \" + line);\n            }\n        }\n\n        if (sourceLines.size() > 0 || targetLines.size() > 0) {\n            hunks.add(new Hunk(new Range(sourceStart, sourceLines.size()), sourceLines, sourceHasNewlineAtEndOfFile,\n                               new Range(targetStart, targetLines.size()), targetLines, targetHasNewlineAtEndOfFile));\n        }\n\n        return hunks;\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/VCS.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\npublic enum VCS {\n    GIT {\n        @Override\n        public String shortName() {\n            return \"git\";\n        }\n\n        @Override\n        public String fullName() {\n            return \"Git\";\n        }\n    },\n    HG {\n        @Override\n        public String shortName() {\n            return \"hg\";\n        }\n\n        @Override\n        public String fullName() {\n            return \"Mercurial\";\n        }\n    };\n\n    public abstract String shortName();\n    public abstract String fullName();\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/WebrevStats.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.nio.file.Path;\nimport java.util.Objects;\nimport java.util.Optional;\n\npublic class WebrevStats {\n    private final int added;\n    private final int removed;\n    private final int modified;\n\n    public WebrevStats(int added, int removed, int modified) {\n        this.added = added;\n        this.removed = removed;\n        this.modified = modified;\n    }\n\n    public int added() {\n        return added;\n    }\n\n    public int removed() {\n        return removed;\n    }\n\n    public int modified() {\n        return modified;\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(added, removed, modified);\n    }\n\n    @Override\n    public boolean equals(Object other) {\n        if (other == this) {\n            return true;\n        }\n\n        if (!(other instanceof WebrevStats o)) {\n            return false;\n        }\n\n        return Objects.equals(added, o.added) &&\n               Objects.equals(removed, o.removed) &&\n               Objects.equals(modified, o.modified);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/git/GitCombinedDiffParser.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.git;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.tools.*;\n\nimport java.io.IOException;\nimport java.nio.file.Path;\nimport java.util.*;\n\nclass GitCombinedDiffParser {\n    private final List<Hash> bases;\n    private final int numParents;\n    private final Hash head;\n    private final String delimiter;\n    private String line = null;\n\n    public GitCombinedDiffParser(List<Hash> bases, Hash head, String delimiter) {\n        this.bases = bases;\n        this.numParents = bases.size();\n        this.head = head;\n        this.delimiter = delimiter;\n    }\n\n    private List<List<Hunk>> parseSingleFileMultiParentDiff(UnixStreamReader reader, List<PatchHeader> headers) throws IOException {\n        if (!line.startsWith(\"diff --combined\")) {\n            throw new IllegalStateException(\"Expected line to start with 'diff --line combined', got: \" + line);\n        }\n\n        var filename = line.substring(\"diff --combined \".length());\n        var isRenamedWithRegardsToAllParents = headers.stream().allMatch(h -> h.status().isRenamed());\n        var isCopiedWithRegardsToAllParents = headers.stream().allMatch(h -> h.status().isCopied());\n        if (isRenamedWithRegardsToAllParents || isCopiedWithRegardsToAllParents) {\n            // git diff -c does not give a \"diff --combined\" line, nor hunks, for a rename or copy without\n            // modifications.\n            if (headers.stream().noneMatch(h -> filename.equals(h.targetPath().toString()))) {\n                // This diff is for another file, this must have been a rename or copy without modifications.\n                var result = new ArrayList<List<Hunk>>();\n                for (int i = 0; i < numParents; i++) {\n                    result.add(List.of());\n                }\n                return result;\n            }\n        }\n\n        for (var header : headers) {\n            var targetPath = header.targetPath();\n            if (targetPath != null && !targetPath.toString().equals(filename)) {\n                throw new IllegalStateException(\"Got header for file \" + targetPath.toString() +\n                                                \" but hunks for file \" + filename);\n            }\n        }\n\n        while ((line = reader.readLine()) != null &&\n                !line.startsWith(\"@@@\") &&\n                !line.startsWith(\"diff --combined\") &&\n                !line.equals(delimiter)) {\n            // Skip all diff header lines (we already have them via the raw headers)\n            // Note: this will also skip 'Binary files differ...' on purpose\n        }\n\n        var hunksPerParent = new ArrayList<List<Hunk>>(numParents);\n        for (int i = 0; i < numParents; i++) {\n            hunksPerParent.add(new ArrayList<>());\n        }\n\n        while (line != null && line.startsWith(\"@@@\")) {\n            var words = line.split(\"\\\\s\");\n            if (!words[0].startsWith(\"@@@\")) {\n                throw new IllegalStateException(\"Expected word to starts with '@@@', got: \" + words[0]);\n            }\n            var sourceRangesPerParent = new ArrayList<Range>(numParents);\n            for (int i = 1; i <= numParents; i++) {\n                var header = headers.get(i - 1);\n                if (header.status().isAdded()) {\n                    // git reports wrong start for added files, they should\n                    // always have range (0,0), but git reports (1,0)\n                    sourceRangesPerParent.add(new Range(0, 0));\n                } else {\n                    sourceRangesPerParent.add(Range.fromCombinedString(words[i].substring(1))); // skip initial '-'\n                }\n            }\n            var targetRange = Range.fromCombinedString(words[numParents + 1].substring(1)); // skip initial '+'\n\n            var linesPerParent = new ArrayList<List<String>>(numParents);\n            for (int i = 0; i < numParents; i++) {\n                linesPerParent.add(new ArrayList<>());\n            }\n\n            while ((line = reader.readLine()) != null &&\n                   !line.startsWith(\"@@@\") &&\n                   !line.startsWith(\"diff --combined\") &&\n                   !line.equals(delimiter)) {\n                if (line.equals(\"\\\\ No newline at end of file\")) {\n                    continue;\n                }\n\n                var signs = line.substring(0, numParents);\n                var content = line.substring(numParents);\n                for (int i = 0; i < numParents; i++) {\n                    char sign = line.charAt(i);\n                    var lines = linesPerParent.get(i);\n                    if (sign == '-') {\n                        lines.add(\"-\" + content);\n                    } else if (sign == '+') {\n                        lines.add(\"+\" + content);\n                    } else if (sign == ' ') {\n                        var presentInParentFile = !signs.contains(\"-\");\n                        if (presentInParentFile) {\n                            lines.add(\" \" + content);\n                        }\n                    } else {\n                        throw new RuntimeException(\"Unexpected diff line: \" + line);\n                    }\n                }\n            }\n\n            for (int i = 0; i < numParents; i++) {\n                var sourceRange = sourceRangesPerParent.get(i);\n                var lines = linesPerParent.get(i);\n                var hunks = UnifiedDiffParser.parseSingleFileDiff(sourceRange, targetRange, lines);\n                hunksPerParent.get(i).addAll(hunks);\n            }\n        }\n\n        return hunksPerParent;\n    }\n\n    private List<PatchHeader> parseCombinedRawLine(String line) {\n        var headers = new ArrayList<PatchHeader>(numParents);\n        var parts = line.substring(numParents).split(\"\\\\t\");\n        var metadata = parts[0];\n        var words = metadata.split(\" \");\n\n        int index = 0;\n        int end = index + numParents;\n\n        var srcTypes = new ArrayList<FileType>(numParents);\n        while (index < end) {\n            srcTypes.add(FileType.fromOctal(words[index]));\n            index++;\n        }\n        var dstType = FileType.fromOctal(words[index]);\n        index++;\n\n        end = index + numParents;\n        var srcHashes = new ArrayList<Hash>(numParents);\n        while (index < end) {\n            srcHashes.add(new Hash(words[index]));\n            index++;\n        }\n        var dstHash = new Hash(words[index]);\n        index++;\n\n        var statuses = new ArrayList<Status>(numParents);\n        var statusWord = words[index];\n        for (int i = 0; i < statusWord.length(); i++) {\n            statuses.add(Status.from(statusWord.charAt(i)));\n        }\n\n\n        var srcPaths = new ArrayList<Path>(numParents);\n        index = 1;\n        end = index + numParents;\n        while (index < end) {\n            srcPaths.add(Path.of(parts[index]));\n            index++;\n        }\n\n        var dstPath = Path.of(parts[index]);\n\n        for (int i = 0; i < numParents; i++) {\n            var status = statuses.get(i);\n            var srcType = srcTypes.get(i);\n            var srcPath = status.isAdded() ? null : srcPaths.get(i);\n            var srcHash = srcHashes.get(i);\n            headers.add(new PatchHeader(srcPath, srcType, srcHash,  dstPath, dstType, dstHash, status));\n        }\n\n        return headers;\n    }\n\n    public List<Diff> parse(UnixStreamReader reader) throws IOException {\n        line = reader.readLine();\n\n        if (line == null || line.equals(delimiter)) {\n            // Not all merge commits contains non-trivial changes\n            var diffsPerParent = new ArrayList<Diff>(numParents);\n            for (int i = 0; i < numParents; i++) {\n                diffsPerParent.add(new Diff(bases.get(i), head, new ArrayList<>()));\n            }\n            return diffsPerParent;\n        }\n\n        var headersPerParent = new ArrayList<List<PatchHeader>>(numParents);\n        for (int i = 0; i < numParents; i++) {\n            headersPerParent.add(new ArrayList<>());\n        }\n\n        var headersForFiles = new ArrayList<List<PatchHeader>>();\n        while (line != null && line.startsWith(\"::\")) {\n            var headersForFile = parseCombinedRawLine(line);\n            headersForFiles.add(headersForFile);\n            if (headersForFile.size() != numParents) {\n                throw new IllegalStateException(\"Expected one raw diff line per parent, have \" +\n                                                numParents + \" parents and got \" + headersForFile.size() +\n                                                \" raw diff lines\");\n            }\n\n            for (int i = 0; i < numParents; i++) {\n                headersPerParent.get(i).add(headersForFile.get(i));\n            }\n\n            line = reader.readLine();\n        }\n\n        // skip empty newline added by git\n        if (!line.equals(\"\")) {\n            throw new IllegalStateException(\"Expected empty line, got: \" + line);\n        }\n        line = reader.readLine();\n\n        var hunksPerFilePerParent = new ArrayList<List<List<Hunk>>>(numParents);\n        for (int i = 0; i < numParents; i++) {\n            hunksPerFilePerParent.add(new ArrayList<>());\n        }\n\n        int headerIndex = 0;\n        while (line != null && !line.equals(delimiter)) {\n            var headersForFile = headersForFiles.get(headerIndex);\n            var hunksPerParentForFile = parseSingleFileMultiParentDiff(reader, headersForFile);\n\n            if (hunksPerParentForFile.size() != numParents) {\n                throw new IllegalStateException(\"Expected at least one hunk per parent, have \" +\n                                                numParents + \" parents and got \" + hunksPerParentForFile.size() +\n                                                \" hunk lists\");\n            }\n\n            for (int i = 0; i < numParents; i++) {\n                hunksPerFilePerParent.get(i).add(hunksPerParentForFile.get(i));\n            }\n\n            headerIndex++;\n        }\n\n        var patchesPerParent = new ArrayList<List<Patch>>(numParents);\n        for (int i = 0; i < numParents; i++) {\n            var headers = headersPerParent.get(i);\n            var hunks = hunksPerFilePerParent.get(i);\n            if (headers.size() != hunks.size()) {\n                throw new IllegalStateException(\"Header lists and hunk lists differ: \" + headers.size() +\n                                                \" headers vs \" + hunks.size() + \" hunks\");\n            }\n            var patches = new ArrayList<Patch>();\n            for (int j = 0; j < headers.size(); j++) {\n                var h = headers.get(j);\n                var hunksForParentPatch = hunks.get(j);\n                patches.add(new TextualPatch(h.sourcePath(), h.sourceFileType(), h.sourceHash(),\n                                             h.targetPath(), h.targetFileType(), h.targetHash(),\n                                             h.status(), hunksForParentPatch));\n            }\n            patchesPerParent.add(patches);\n        }\n\n        var diffs = new ArrayList<Diff>(numParents);\n        for (int i = 0; i < numParents; i++) {\n            diffs.add(new Diff(bases.get(i), head, patchesPerParent.get(i)));\n        }\n        return diffs;\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/git/GitCommitIterator.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.git;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.tools.*;\n\nimport java.io.*;\nimport java.util.*;\nimport java.time.Instant;\n\nclass GitCommitIterator implements Iterator<Commit> {\n    private final UnixStreamReader reader;\n    private final String commitDelimiter;\n    private String line;\n\n    public GitCommitIterator(UnixStreamReader reader, String commitDelimiter) {\n        this.reader = reader;\n        this.commitDelimiter = commitDelimiter;\n        try {\n            line = reader.readLine();\n        } catch (IOException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    @Override\n    public boolean hasNext() {\n        return line != null;\n    }\n\n    public Commit next() {\n        if (line == null) {\n            return null;\n        }\n\n        try {\n            if (!line.equals(commitDelimiter)) {\n                throw new IllegalStateException(\"Unexpected line: \" + line);\n            }\n\n            var metadata = GitCommitMetadata.read(reader);\n\n            line = reader.readLine();   // read empty line before patches\n            if (line == null || line.equals(commitDelimiter)) {\n                // commit without patches\n                var parentDiffs = new ArrayList<Diff>();\n                for (var parentHash : metadata.parents()) {\n                    parentDiffs.add(new Diff(parentHash, metadata.hash(), Collections.emptyList()));\n                }\n                return new Commit(metadata, parentDiffs);\n            }\n\n            if (!line.equals(\"\")) {\n                throw new IllegalStateException(\"Unexpected line: \" + line);\n            }\n\n            var hash = metadata.hash();\n            var parents = metadata.parents();\n\n            List<Diff> parentDiffs = null;\n            if (parents.size() == 1) {\n                var patches = GitRawDiffParser.parse(reader, commitDelimiter);\n                parentDiffs = List.of(new Diff(parents.get(0), hash, patches));\n            } else {\n                parentDiffs = new GitCombinedDiffParser(parents, hash, commitDelimiter).parse(reader);\n            }\n            line = reader.lastLine(); // update state for hasNext\n\n            return new Commit(metadata, parentDiffs);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/git/GitCommitMetadata.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.git;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.tools.*;\n\nimport java.io.IOException;\nimport java.util.*;\nimport java.time.*;\nimport java.time.format.*;\nimport java.nio.charset.StandardCharsets;\nimport java.util.logging.Logger;\n\nclass GitCommitMetadata {\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.vcs.git\");\n\n    private static final String hashFormat = \"%H\";\n    private static final String parentsFormat = \"%P\";\n    private static final String authorNameFormat = \"%an\";\n    private static final String authorEmailFormat = \"%ae\";\n    private static final String authorDateFormat = \"%aI\";\n    private static final String committerNameFormat = \"%cn\";\n    private static final String committerEmailFormat = \"%ce\";\n    private static final String committerDateFormat = \"%cI\";\n\n    private static final String messageDelimiter = \"=@=@=@=@=@\";\n    private static final String messageFormat = \"%B\" + messageDelimiter;\n\n    public static final String FORMAT = String.join(\"%n\",\n                                                    hashFormat,\n                                                    parentsFormat,\n                                                    authorNameFormat,\n                                                    authorEmailFormat,\n                                                    authorDateFormat,\n                                                    committerNameFormat,\n                                                    committerEmailFormat,\n                                                    committerDateFormat,\n                                                    messageFormat);\n\n    public static CommitMetadata read(UnixStreamReader reader) throws IOException {\n        var hash = new Hash(reader.readLine());\n        log.finest(\"Parsing: \" + hash.hex());\n\n        var parentHashes = reader.readLine();\n        if (parentHashes.equals(\"\")) {\n            parentHashes = Hash.zero().hex();\n        }\n        var parents = new ArrayList<Hash>();\n        for (var parentHash : parentHashes.split(\" \")) {\n            parents.add(new Hash(parentHash));\n        }\n\n        var dateFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;\n\n        var authorName = reader.readLine();\n        log.finest(\"authorName: \" + authorName);\n        var authorEmail = reader.readLine();\n        log.finest(\"authorEmail: \" + authorEmail);\n        var author = new Author(authorName, authorEmail);\n        var authored = ZonedDateTime.parse(reader.readLine(), dateFormatter);\n        log.finest(\"authorDate: \" + authored);\n\n        var committerName = reader.readLine();\n        log.finest(\"committerName: \" + committerName);\n        var committerEmail = reader.readLine();\n        log.finest(\"committerEmail \" + committerName);\n        var committer = new Author(committerName, committerEmail);\n        var committed = ZonedDateTime.parse(reader.readLine(), dateFormatter);\n        log.finest(\"committerDate: \" + committed);\n\n\n        var message = new ArrayList<String>();\n        var line = reader.readLine();\n        while (!line.endsWith(messageDelimiter)) {\n            message.add(line);\n            line = reader.readLine();\n        }\n        // the last commit message doesn't have to end with '\\n'\n        if (!line.equals(messageDelimiter)) {\n            var prefix = line.substring(0, line.length() - messageDelimiter.length());\n            message.add(prefix);\n        }\n\n        return new CommitMetadata(hash, parents, author, authored, committer, committed, message);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/git/GitCommits.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.git;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.tools.*;\n\nimport java.nio.charset.StandardCharsets;\nimport java.util.concurrent.TimeUnit;\nimport java.io.*;\nimport java.util.*;\nimport java.nio.file.Path;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\nclass GitCommits implements Commits, AutoCloseable {\n\n    private final static Logger log = Logger.getLogger(\"org.openjdk.skara.vcs.git.GitCommits\");\n\n    private static final String COMMIT_DELIMITER = \"#@!_-=&\";\n\n    private final Path dir;\n    private final String range;\n    private final List<Hash> from;\n    private final List<Hash> notFrom;\n    private final boolean reverse;\n    private final int num;\n    private final String format;\n\n    private final List<Process> processes = new ArrayList<>();\n    private final List<List<String>> commands = new ArrayList<>();\n    private boolean closed = false;\n\n    public GitCommits(Path dir, String range, boolean reverse, int num) throws IOException {\n        this.dir = dir;\n        this.range = range;\n        this.from = null;\n        this.notFrom = null;\n        this.reverse = reverse;\n        this.num = num;\n        this.format = String.join(\"%n\",\n                                  COMMIT_DELIMITER,\n                                  GitCommitMetadata.FORMAT);\n\n    }\n\n    public GitCommits(Path dir, List<Hash> reachableFrom, List<Hash> unreachableFrom) throws IOException {\n        this.dir = dir;\n        this.range = null;\n        this.reverse = false;\n        this.num = -1;\n        this.from = reachableFrom;\n        this.notFrom = unreachableFrom;\n        this.format = String.join(\"%n\",\n                                  COMMIT_DELIMITER,\n                                  GitCommitMetadata.FORMAT);\n\n    }\n\n    @Override\n    public Iterator<Commit> iterator() {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"-c\", \"core.quotePath=false\", \"log\", \"--format=\" + format,\n                                         \"--patch\",\n                                         \"--find-renames=90%\",\n                                         \"--find-copies=90%\",\n                                         \"--find-copies-harder\",\n                                         \"--topo-order\",\n                                         \"--binary\",\n                                         \"-c\",\n                                         \"--combined-all-paths\",\n                                         \"--raw\",\n                                         \"--no-abbrev\",\n                                         \"--unified=0\",\n                                         \"--no-color\"));\n        if (reverse) {\n            cmd.add(\"--reverse\");\n        }\n        if (num > 0) {\n            cmd.add(\"-n\");\n            cmd.add(Integer.toString(num));\n        }\n        if (range != null) {\n            cmd.add(range);\n        } else {\n            cmd.addAll(from.stream().map(Hash::hex).toList());\n            if (!notFrom.isEmpty()) {\n                cmd.add(\"--not\");\n                cmd.addAll(notFrom.stream().map(Hash::hex).toList());\n            }\n        }\n        var pb = new ProcessBuilder(cmd);\n        pb.directory(dir.toFile());\n        pb.environment().putAll(GitRepository.currentEnv);\n        var command = pb.command();\n        try {\n            var p = pb.start();\n            processes.add(p);\n            commands.add(command);\n            var reader = new UnixStreamReader(p.getInputStream());\n\n            return new GitCommitIterator(reader, COMMIT_DELIMITER);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public void close() throws IOException {\n        synchronized (this) {\n            if (!closed) {\n                closed = true;\n            } else {\n                return;\n            }\n        }\n\n        Exception exception = null;\n\n        for (var i = 0; i < processes.size(); i++) {\n            var p = processes.get(i);\n            var command = commands.get(i);\n            try {\n                close(p, command);\n            } catch (IOException | RuntimeException e) {\n                if (exception == null) {\n                    exception = e;\n                } else {\n                    exception.addSuppressed(e);\n                }\n            }\n        }\n\n        if (exception != null) {\n            if (exception instanceof IOException) {\n                throw (IOException) exception;\n            } else {\n                throw (RuntimeException) exception;\n            }\n        }\n    }\n\n    private void close(Process p, List<String> command) throws IOException {\n        log.finer(\"Waiting for the process to terminate: pid=\" + p.pid()\n                + \", command=\" + Arrays.toString(command.toArray()));\n        try {\n            var exited = p.waitFor(30L, TimeUnit.SECONDS);\n            if (!exited) {\n                throw new IOException(\"'\" + String.join(\" \", command) + \"' timed out, pid=\" + p.pid());\n            }\n            log.finer(\"Terminated: pid=\" + p.pid());\n            var exitCode = p.exitValue();\n            if (exitCode != 0) {\n                var stderr = new BufferedReader(new InputStreamReader(p.getErrorStream(), StandardCharsets.UTF_8));\n                var message = stderr.lines().collect(Collectors.joining(\"\\n\"));\n                log.finer(\"stderr for pid=\" + p.pid() + \": \" + message);\n                if (exitCode == 128) {\n                    if (message.equals(\"fatal: bad default revision 'HEAD'\")) {\n                        // this is an empty repository, this is not an error case\n                    } else {\n                        throw new IOException(\"'\" + String.join(\" \", command) + \"' exited with code: \" + exitCode);\n                    }\n                }\n            }\n        } catch (InterruptedException e) {\n            throw new IOException(\"'\" + String.join(\" \", command) + \"' was interrupted\", e);\n        } finally {\n            if (p.isAlive()) {\n                log.finer(\"Destroying the process pid=\" + p.pid());\n                p.destroy();\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/git/GitRepository.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.git;\n\nimport org.openjdk.skara.process.*;\nimport org.openjdk.skara.process.Process;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.tools.*;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.nio.file.*;\nimport java.nio.charset.StandardCharsets;\nimport java.time.*;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\npublic class GitRepository implements Repository {\n    private final static Map<String, String> NO_CONFIG_ENV = Map.of(\n            \"HOME\", \"/this-does-not-exist-and-if-you-create-it-you-are-in-trouble\",\n            \"XDG_CONFIG_HOME\", \"/this-does-not-exist-and-if-you-create-it-you-are-in-trouble\",\n            \"GIT_CONFIG_NOSYSTEM\", \"true\"\n    );\n\n    public static Map<String, String> currentEnv = Collections.emptyMap();\n    private final Path dir;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.vcs.git\");\n    private Path cachedRoot = null;\n    private static final Hash EMPTY_TREE = new Hash(\"4b825dc642cb6eb9a060e54bf8d69288fbee4904\");\n\n    public static void ignoreConfiguration() {\n        currentEnv = NO_CONFIG_ENV;\n    }\n\n    private java.lang.Process start(String... cmd) throws IOException {\n        return start(Arrays.asList(cmd));\n    }\n\n    private java.lang.Process start(List<String> cmd) throws IOException {\n        log.fine(\"Executing \" + String.join(\" \", cmd));\n        var pb = new ProcessBuilder(cmd);\n        pb.directory(dir.toFile());\n        pb.environment().putAll(currentEnv);\n        pb.redirectError(ProcessBuilder.Redirect.DISCARD);\n        return pb.start();\n    }\n\n    private static void stop(java.lang.Process p) throws IOException {\n        if (p != null && p.isAlive()) {\n            var stream = p.getInputStream();\n            var read = 0;\n            var buf = new byte[128];\n            while (read != -1) {\n                read = stream.read(buf);\n            }\n            try {\n                p.waitFor();\n            } catch (InterruptedException e) {\n                throw new IOException(e);\n            }\n        }\n    }\n\n    private Execution capture(List<String> cmd) {\n        return capture(cmd.toArray(new String[0]));\n    }\n\n    private static Execution capture(Path cwd, Map<String, String> env, List<String> cmd) {\n        return capture(cwd, env, cmd.toArray(new String[0]));\n    }\n\n    private Execution capture(String... cmd) {\n        return capture(dir, cmd);\n    }\n\n    public static Execution capture(Path cwd, String... cmd) {\n        return capture(cwd, currentEnv, cmd);\n    }\n\n    private static Execution capture(Path cwd, Map<String, String> env, String... cmd) {\n        return Process.capture(cmd)\n                      .workdir(cwd)\n                      .environ(env)\n                      .execute();\n    }\n\n    private static Execution capture(Path cwd, List<String> cmd) {\n        return capture(cwd, cmd.toArray(new String[0]));\n    }\n\n    private static Execution.Result await(Execution e) throws IOException {\n        var result = e.await();\n        if (result.status() != 0) {\n            throw new IOException(\"Unexpected exit code\\n\" + result);\n        }\n        return result;\n    }\n\n    private static void await(java.lang.Process p) throws IOException {\n        try {\n            var res = p.waitFor();\n            if (res != 0) {\n                throw new IOException(\"Unexpected exit code: \" + res);\n            }\n        } catch (InterruptedException e) {\n            throw new IOException(e);\n        }\n    }\n\n    public GitRepository(Path dir) {\n        this.dir = dir.toAbsolutePath();\n    }\n\n    public List<Branch> branches() throws IOException {\n        try (var p = capture(\"git\", \"for-each-ref\", \"--format=%(refname:short)\", \"refs/heads\")) {\n            return await(p).stdout()\n                           .stream()\n                           .map(Branch::new)\n                           .collect(Collectors.toList());\n        }\n    }\n\n    public List<Branch> branches(String remote) throws IOException {\n        try (var p = capture(\"git\", \"for-each-ref\", \"--format=%(refname:short)\", \"refs/remotes/\" + remote + \"/\")) {\n            return await(p).stdout()\n                           .stream()\n                           .map(Branch::new)\n                           .collect(Collectors.toList());\n        }\n    }\n\n    public List<Tag> tags() throws IOException {\n        try (var p = capture(\"git\", \"for-each-ref\", \"--format=%(refname:short)\", \"refs/tags\")) {\n            return await(p).stdout()\n                           .stream()\n                           .map(Tag::new)\n                           .collect(Collectors.toList());\n        }\n    }\n\n    @Override\n    public Commits commits() throws IOException {\n        return new GitCommits(dir, \"--all\", false, -1);\n    }\n\n    @Override\n    public Commits commits(int n) throws IOException {\n        return new GitCommits(dir, \"--all\", false, n);\n    }\n\n    @Override\n    public Commits commits(boolean reverse) throws IOException {\n        return new GitCommits(dir, \"--all\", reverse, -1);\n    }\n\n    @Override\n    public Commits commits(int n, boolean reverse) throws IOException {\n        return new GitCommits(dir, \"--all\", reverse, n);\n    }\n\n    @Override\n    public Commits commits(List<Hash> reachableFrom, List<Hash> unreachableFrom) throws IOException {\n        return new GitCommits(dir, reachableFrom, unreachableFrom);\n    }\n\n    @Override\n    public Commits commits(String range) throws IOException {\n        return new GitCommits(dir, range, false, -1);\n    }\n\n    @Override\n    public Commits commits(String range, int n) throws IOException {\n        return new GitCommits(dir, range, false, n);\n    }\n\n    @Override\n    public Commits commits(String range, boolean reverse) throws IOException {\n        return new GitCommits(dir, range, reverse, -1);\n    }\n\n    @Override\n    public Commits commits(String range, int n, boolean reverse) throws IOException {\n        return new GitCommits(dir, range, reverse, n);\n    }\n\n    @Override\n    public boolean contains(Hash h) throws IOException {\n        try (var p = capture(\"git\", \"cat-file\", \"-e\", h.hex())) {\n            var res = p.await();\n            return res.status() == 0;\n        }\n    }\n\n    @Override\n    public Optional<Commit> lookup(Hash h) throws IOException {\n        if (!contains(h)) {\n            return Optional.empty();\n        }\n\n        var commits = commits(h.hex(), 1).asList();\n        if (commits.size() != 1) {\n            return Optional.empty();\n        }\n        return Optional.of(commits.get(0));\n    }\n\n    @Override\n    public Optional<Commit> lookup(Branch b) throws IOException {\n        var hash = resolve(b.name()).orElseThrow(() -> new IOException(\"Branch \" + b.name() + \" not found\"));\n        return lookup(hash);\n    }\n\n    @Override\n    public Optional<Commit> lookup(Tag t) throws IOException {\n        var hash = resolve(t.name()).orElseThrow(() -> new IOException(\"Tag \" + t.name() + \" not found\"));\n        return lookup(hash);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(String range, List<Path> paths, boolean reverse) throws IOException {\n        var args = new ArrayList<String>();\n        args.addAll(List.of(\"git\", \"rev-list\",\n                                   \"--format=\" + GitCommitMetadata.FORMAT,\n                                   \"--topo-order\",\n                                   \"--no-abbrev\",\n                                   \"--no-color\",\n                                   range));\n        if (reverse) {\n            args.add(\"--reverse\");\n        }\n        if (paths != null && !paths.isEmpty()) {\n            args.add(\"--\");\n            for (var path : paths) {\n                args.add(path.toString());\n            }\n        }\n        return readMetadata(args, \"commit \");\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadataFor(List<Branch> branches) throws IOException {\n        var args = new ArrayList<String>();\n        args.addAll(List.of(\"git\", \"rev-list\",\n                                   \"--format=\" + GitCommitMetadata.FORMAT,\n                                   \"--topo-order\",\n                                   \"--no-abbrev\",\n                                   \"--no-color\"));\n        args.addAll(branches.stream().map(Branch::name).collect(Collectors.toList()));\n        args.add(\"--\");\n        return readMetadata(args, \"commit \");\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(Hash from, Hash to, List<Path> paths, boolean reverse) throws IOException {\n        return commitMetadata(from.hex() + \"..\" + to.hex(), paths, reverse);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(String range, List<Path> paths) throws IOException {\n        return commitMetadata(range, paths, false);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(Hash from, Hash to, List<Path> paths) throws IOException {\n        return commitMetadata(from.hex() + \"..\" + to.hex(), paths, false);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(boolean reverse) throws IOException {\n        return commitMetadata(\"--all\", List.of(), reverse);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(String range) throws IOException {\n        return commitMetadata(range, List.of(), false);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(Hash from, Hash to) throws IOException {\n        return commitMetadata(from.hex() + \"..\" + to.hex(), List.of(), false);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(String range, boolean reverse) throws IOException {\n        return commitMetadata(range, List.of(), reverse);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(Hash from, Hash to, boolean reverse) throws IOException {\n        return commitMetadata(from.hex() + \"..\" + to.hex(), List.of(), reverse);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(List<Path> paths) throws IOException {\n        return commitMetadata(\"--all\", paths, false);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(List<Path> paths, boolean reverse) throws IOException {\n        return commitMetadata(\"--all\", paths, reverse);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata() throws IOException {\n        return commitMetadata(\"--all\");\n    }\n\n    private List<CommitMetadata> readMetadata(List<String> cmd, String delimiter) throws IOException {\n        var p = start(cmd);\n        var reader = new UnixStreamReader(p.getInputStream());\n        var result = new ArrayList<CommitMetadata>();\n\n        var line = reader.readLine();\n        while (line != null) {\n            if (!line.startsWith(delimiter)) {\n                throw new IOException(\"Unexpected line: \" + line);\n            }\n\n            result.add(GitCommitMetadata.read(reader));\n            line = reader.readLine();\n        }\n\n        await(p);\n        return result;\n    }\n\n    @Override\n    public List<CommitMetadata> follow(Path path) throws IOException {\n        return follow(path, null, null);\n    }\n\n    @Override\n    public List<CommitMetadata> follow(Path path, Hash from, Hash to) throws IOException {\n        var delimiter = \"#@!_-=&\";\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"log\",\n                                  \"-c\",\n                                  \"--no-patch\",\n                                  \"--full-history\",\n                                  \"--follow\",\n                                  \"--format=\" + delimiter + \"\\n\" + GitCommitMetadata.FORMAT,\n                                  \"--topo-order\",\n                                  \"--no-abbrev\",\n                                  \"--no-color\"));\n        if (from != null && to != null) {\n            cmd.add(from.hex() + \"..\" + to.hex());\n        }\n        cmd.add(\"--\");\n        cmd.add(path.toString());\n        return readMetadata(cmd, delimiter);\n    }\n\n\n    private List<Hash> refs() throws IOException {\n        try (var p = capture(\"git\", \"show-ref\", \"--hash\", \"--abbrev\")) {\n            var res = p.await();\n            if (res.status() == -1) {\n                if (res.stdout().size() != 0) {\n                    throw new IOException(\"Unexpected output\\n\" + res);\n                }\n                return new ArrayList<>();\n            } else {\n                return res.stdout().stream()\n                          .map(Hash::new)\n                          .collect(Collectors.toList());\n            }\n        }\n    }\n\n    @Override\n    public boolean isEmpty() throws IOException {\n        int numLooseObjects = -1;\n        int numPackedObjects = -1;\n\n        try (var p = capture(\"git\", \"count-objects\", \"-v\")) {\n            var res = await(p);\n            var stdout = res.stdout();\n\n            for (var line : stdout) {\n                if (line.startsWith(\"count: \")) {\n                    try {\n                        numLooseObjects = Integer.parseUnsignedInt(line.split(\" \")[1]);\n                    } catch (NumberFormatException e) {\n                        throw new IOException(\"Unexpected 'count' value\\n\" + res, e);\n                    }\n\n                } else if (line.startsWith(\"in-pack: \")) {\n                    try {\n                        numPackedObjects = Integer.parseUnsignedInt(line.split(\" \")[1]);\n                    } catch (NumberFormatException e) {\n                        throw new IOException(\"Unexpected 'in-pack' value\\n\" + res, e);\n                    }\n                }\n            }\n        }\n\n        return numLooseObjects == 0 && numPackedObjects == 0 && refs().size() == 0;\n    }\n\n    @Override\n\n    public boolean isHealthy() throws IOException {\n        try (var p = capture(\"git\", \"fsck\", \"--connectivity-only\")) {\n            if (p.await().status() != 0) {\n                return false;\n            }\n        }\n        return true;\n    }\n\n    @Override\n    public void clean() throws IOException {\n        cachedRoot = null;\n\n        try (var p = capture(\"git\", \"clean\", \"-x\", \"-d\", \"--force\", \"--force\")) {\n            await(p);\n        }\n\n        try (var p = capture(\"git\", \"reset\", \"--hard\")) {\n            await(p);\n        }\n\n        try (var p = capture(\"git\", \"rebase\", \"--quit\")) {\n            p.await(); // Don't care about the result.\n        }\n    }\n\n    @Override\n    public void deleteUntrackedFiles() throws IOException {\n        var root = root();\n        try (var p = capture(\"git\", \"ls-files\", \"--full-name\", \"--other\")) {\n            var res = await(p);\n            for (var line : res.stdout()) {\n                Files.delete(root.resolve(line));\n            }\n        }\n    }\n\n    @Override\n    public void reset(Hash target, boolean hard) throws IOException {\n        var cmd = new ArrayList<>(List.of(\"git\", \"reset\"));\n        if (hard) {\n           cmd.add(\"--hard\");\n        }\n        cmd.add(target.hex());\n\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n\n    @Override\n    public void revert(Hash h) throws IOException {\n        try (var p = capture(\"git\", \"restore\", \"--recurse-submodules\", \"--source\", h.hex(), \"--\", \".\")) {\n            await(p);\n        }\n    }\n\n    @Override\n    public Repository reinitialize() throws IOException {\n        cachedRoot = null;\n\n        try (var paths = Files.walk(dir)) {\n            paths.map(Path::toFile)\n                 .sorted(Comparator.reverseOrder())\n                 .forEach(File::delete);\n        }\n\n        return init();\n    }\n\n    @Override\n    public Optional<Hash> fetch(URI uri, String refspec, boolean includeTags, boolean forceUpdateTags) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"fetch\", \"--recurse-submodules=on-demand\"));\n        if (includeTags) {\n            cmd.add(\"--tags\");\n            if (forceUpdateTags) {\n                cmd.add(\"--force\");\n            }\n        } else {\n            cmd.add(\"--no-tags\");\n        }\n        cmd.add(uri.toString());\n        cmd.add(refspec);\n        try (var p = capture(cmd)) {\n            await(p);\n            return resolve(\"FETCH_HEAD\");\n        }\n    }\n\n    @Override\n    public void fetchAll(URI uri, boolean includeTags) throws IOException {\n        var cmd = new ArrayList<>(List.of(\"git\", \"fetch\", \"--recurse-submodules=on-demand\", \"--prune\", uri.toString()));\n        cmd.add(\"+refs/heads/*:refs/heads/*\");\n        if (includeTags) {\n            cmd.add(\"+refs/tags/*:refs/tags/*\");\n        }\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void fetchAllRemotes(boolean includeTags) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"fetch\", \"--recurse-submodules=on-demand\"));\n        cmd.add(\"--prune\");\n        if (includeTags) {\n            cmd.add(\"--tags\");\n            cmd.add(\"--prune-tags\");\n        } else {\n            cmd.add(\"--no-tags\");\n        }\n        cmd.add(\"--all\");\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void fetchRemote(String remote) throws IOException {\n        var lines = config(\"remote.\" + remote + \".fetch\");\n        var refspec = lines.size() == 1 ? lines.get(0) : \"+refs/heads/*:refs/remotes/\" + remote + \"/*\";\n        try (var p = capture(\"git\", \"fetch\", \"--recurse-submodules=on-demand\", \"--prune\", remote, refspec, \"+refs/tags/*:refs/tags/*\")) {\n            await(p);\n        }\n    }\n\n    private void checkout(String ref, boolean force) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"-c\", \"advice.detachedHead=false\", \"checkout\", \"--recurse-submodules\"));\n        if (force) {\n            cmd.add(\"--force\");\n        }\n        cmd.add(ref);\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void checkout(Hash h, boolean force) throws IOException {\n        checkout(h.hex(), force);\n    }\n\n    @Override\n    public void checkout(Branch b, boolean force) throws IOException {\n        checkout(b.name(), force);\n    }\n\n    @Override\n    public Repository init() throws IOException {\n        cachedRoot = null;\n\n        if (!Files.exists(dir)) {\n            Files.createDirectories(dir);\n        }\n\n        try (var p = capture(\"git\", \"init\")) {\n            await(p);\n            return this;\n        }\n    }\n\n    @Override\n    public void pushAll(URI uri, boolean force) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"push\", \"--mirror\"));\n        if (force) {\n            cmd.add(\"--force\");\n        }\n        cmd.add(uri.toString());\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void pushTags(URI uri, boolean force) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"push\", \"--tags\"));\n        if (force) {\n            cmd.add(\"--force\");\n        }\n        cmd.add(uri.toString());\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void push(Hash hash, URI uri, String ref, boolean force, boolean includeTags) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"push\"));\n\n        if (includeTags) {\n            cmd.add(\"--tags\");\n            if (force) {\n                cmd.add(\"--force\");\n            }\n        }\n\n        cmd.add(uri.toString());\n\n        /*\n         * https://git-scm.com/docs/git-push\n         * Specify what destination ref to update with what source object.\n         * The format of a <refspec> parameter is an optional plus +, followed by\n         * the source object, followed by a colon : and finally by the destination\n         * ref.\n         */\n        String refspec = force ? \"+\" : \"\";\n        if (!ref.startsWith(\"refs/\")) {\n            ref = \"refs/heads/\" + ref;\n        }\n        refspec += hash.hex() + \":\" + ref;\n        cmd.add(refspec);\n\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void push(Tag tag, URI uri, boolean force) throws IOException {\n        var refspec = force ? \"+\" : \"\";\n        refspec += \"refs/tags/\" + tag.name() + \":refs/tags/\" + tag.name();\n\n        try (var p = capture(\"git\", \"push\", uri.toString(), refspec)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void push(String refspec, URI uri) throws IOException {\n        try (var p = capture(\"git\", \"push\", uri.toString(), refspec)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void push(Branch branch, String remote, boolean setUpstream) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"push\", remote, branch.name()));\n        if (setUpstream) {\n            cmd.add(\"--set-upstream\");\n        }\n\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public boolean isClean() throws IOException {\n        try (var p = capture(\"git\", \"status\", \"--porcelain\")) {\n            var output = await(p);\n            return output.stdout().size() == 0;\n        }\n    }\n\n    @Override\n    public boolean exists() throws IOException {\n        if (!Files.exists(dir)) {\n            return false;\n        }\n\n        try (var p = capture(\"git\", \"rev-parse\", \"--git-dir\")) {\n            return p.await().status() == 0;\n        }\n    }\n\n    @Override\n    public Path root() throws IOException {\n        if (cachedRoot != null) {\n            return cachedRoot;\n        }\n\n        try (var p = capture(\"git\", \"rev-parse\", \"--show-toplevel\")) {\n            var res = p.await();\n            if (res.status() != 0 || res.stdout().size() != 1) {\n                // Perhaps this is a bare repository\n                try (var p2 = capture(\"git\", \"rev-parse\", \"--git-dir\")) {\n                    var res2 = await(p2);\n                    if (res2.stdout().size() != 1) {\n                        throw new IOException(\"Unexpected output\\n\" + res2);\n                    }\n                    cachedRoot = dir.resolve(Path.of(res2.stdout().get(0)));\n                    return cachedRoot;\n                }\n            }\n\n            cachedRoot = Path.of(res.stdout().get(0));\n            return cachedRoot;\n        }\n    }\n\n    @Override\n    public void squash(Hash h) throws IOException {\n        try (var p = capture(\"git\", \"merge\", \"--squash\", h.hex())) {\n            await(p);\n        }\n    }\n\n    @FunctionalInterface\n    private static interface Operation {\n        void execute(List<Path> args) throws IOException;\n    }\n\n    private void batch(Operation op, List<Path> args) throws IOException {\n        var batchSize = 64;\n        var start = 0;\n        while (start < args.size()) {\n            var end = start + batchSize;\n            if (end > args.size()) {\n                end = args.size();\n            }\n            op.execute(args.subList(start, end));\n            start = end;\n        }\n    }\n\n    private void addAll(List<Path> paths) throws IOException {\n        var cmd = new ArrayList<>(List.of(\"git\", \"add\"));\n        for (var path : paths) {\n            cmd.add(path.toString());\n        }\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void add(List<Path> paths) throws IOException {\n        batch(this::addAll, paths);\n    }\n\n    private void removeAll(List<Path> paths) throws IOException {\n        var cmd = new ArrayList<>(List.of(\"git\", \"rm\"));\n        for (var path : paths) {\n            cmd.add(path.toString());\n        }\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void remove(List<Path> paths) throws IOException {\n        batch(this::removeAll, paths);\n    }\n\n    @Override\n    public void delete(Branch b) throws IOException {\n        try (var p = capture(\"git\", \"branch\", \"-D\", b.name())) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void addremove() throws IOException {\n        try (var p = capture(\"git\", \"add\", \"--all\")) {\n            await(p);\n        }\n    }\n\n    @Override\n    public Hash commit(String message, String authorName, String authorEmail)  throws IOException {\n        return commit(message, authorName, authorEmail, null);\n    }\n\n    @Override\n    public Hash commit(String message, String authorName, String authorEmail, ZonedDateTime authorDate)  throws IOException {\n        return commit(message, authorName, authorEmail, authorDate, authorName, authorEmail, authorDate);\n    }\n\n    @Override\n    public Hash commit(String message,\n                       String authorName,\n                       String authorEmail,\n                       String committerName,\n                       String committerEmail) throws IOException {\n        return commit(message, authorName, authorEmail, null, committerName, committerEmail, null);\n    }\n\n    @Override\n    public Hash commit(String message,\n                       String authorName,\n                       String authorEmail,\n                       ZonedDateTime authorDate,\n                       String committerName,\n                       String committerEmail,\n                       ZonedDateTime committerDate) throws IOException {\n        var cmd = Process.capture(\"git\", \"commit\", \"--message=\" + message)\n                         .workdir(dir)\n                         .environ(currentEnv)\n                         .environ(\"GIT_AUTHOR_NAME\", authorName)\n                         .environ(\"GIT_AUTHOR_EMAIL\", authorEmail)\n                         .environ(\"GIT_COMMITTER_NAME\", committerName)\n                         .environ(\"GIT_COMMITTER_EMAIL\", committerEmail);\n        if (authorDate != null) {\n            cmd = cmd.environ(\"GIT_AUTHOR_DATE\",\n                              authorDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));\n        }\n        if (committerDate != null) {\n            cmd = cmd.environ(\"GIT_COMMITTER_DATE\",\n                              committerDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));\n        }\n        try (var p = cmd.execute()) {\n            await(p);\n            return head();\n        }\n    }\n\n    @Override\n    public Hash commit(String message, String authorName, String authorEmail, ZonedDateTime authorDate, String committerName, String committerEmail, ZonedDateTime committerDate, List<Hash> parents, Tree tree) throws IOException {\n        // Ensure we don't create identical commits\n        if (parents.size() == 1) {\n            var parentTree = tree(parents.get(0));\n            if (parentTree.equals(tree)) {\n                return parents.get(0);\n            }\n        }\n\n        var cmdLine = new ArrayList<>(List.of(\"git\", \"commit-tree\", tree.hash().hex(), \"-m\", message));\n        for (var parent : parents) {\n            cmdLine.add(\"-p\");\n            cmdLine.add(parent.hex());\n        }\n        var cmd = Process.capture(cmdLine.toArray(new String[0]))\n                .workdir(dir)\n                .environ(currentEnv)\n                .environ(\"GIT_AUTHOR_NAME\", authorName)\n                .environ(\"GIT_AUTHOR_EMAIL\", authorEmail)\n                .environ(\"GIT_COMMITTER_NAME\", committerName)\n                .environ(\"GIT_COMMITTER_EMAIL\", committerEmail);\n        if (authorDate != null) {\n            cmd = cmd.environ(\"GIT_AUTHOR_DATE\",\n                    authorDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));\n        }\n        if (committerDate != null) {\n            cmd = cmd.environ(\"GIT_COMMITTER_DATE\",\n                    committerDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));\n        }\n        try (var p = cmd.execute()) {\n            var res = await(p);\n            if (res.stdout().size() != 1) {\n                throw new IOException(\"Unexpected output: \" + res.stdout());\n            }\n            var commitHash = res.stdout().get(0).trim();\n            if (commitHash.length() != 40) {\n                throw new IOException(\"Unexpected output: \" + commitHash);\n            }\n            return new Hash(commitHash);\n        }\n    }\n\n    @Override\n    public Hash amend(String message) throws IOException {\n        return amend(message, null, null, null, null);\n    }\n\n    @Override\n    public Hash amend(String message, String authorName, String authorEmail) throws IOException {\n        return amend(message, authorName, authorEmail, null, null);\n    }\n\n    @Override\n    public Hash amend(String message, String authorName, String authorEmail, String committerName, String committerEmail) throws IOException {\n        if (authorName == null || authorEmail == null) {\n            var head = lookup(head()).orElseThrow();\n            if (authorName == null) {\n                authorName = head.author().name();\n            }\n            if (authorEmail == null) {\n                authorEmail = head.author().email();\n            }\n        }\n        if (committerName == null) {\n            committerName = authorName;\n            committerEmail = authorEmail;\n        }\n        var cmd = Process.capture(\"git\", \"commit\", \"--amend\", \"--reset-author\", \"--message=\" + message)\n                         .workdir(dir)\n                         .environ(currentEnv)\n                         .environ(\"GIT_AUTHOR_NAME\", authorName)\n                         .environ(\"GIT_AUTHOR_EMAIL\", authorEmail)\n                         .environ(\"GIT_COMMITTER_NAME\", committerName)\n                         .environ(\"GIT_COMMITTER_EMAIL\", committerEmail);\n        try (var p = cmd.execute()) {\n            await(p);\n            return head();\n        }\n    }\n\n    @Override\n    public Tag tag(Hash hash, String name, String message, String authorName, String authorEmail, ZonedDateTime date, boolean force) throws IOException {\n        var cmdLine = new ArrayList<>(List.of(\"git\", \"tag\", \"--annotate\", \"--message=\" + message, name, hash.hex()));\n        if (force) {\n            cmdLine.add(\"--force\");\n        }\n        var cmd = Process.capture(cmdLine.toArray(new String[0]))\n                         .workdir(dir)\n                         .environ(currentEnv)\n                         .environ(\"GIT_AUTHOR_NAME\", authorName)\n                         .environ(\"GIT_AUTHOR_EMAIL\", authorEmail)\n                         .environ(\"GIT_COMMITTER_NAME\", authorName)\n                         .environ(\"GIT_COMMITTER_EMAIL\", authorEmail);\n        if (date != null) {\n            cmd = cmd.environ(\"GIT_AUTHOR_DATE\", date.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));\n            cmd = cmd.environ(\"GIT_COMMITTER_DATE\", date.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));\n        }\n        try (var p = cmd.execute()) {\n            await(p);\n        }\n\n        return new Tag(name);\n    }\n\n    @Override\n    public Branch branch(Hash hash, String name) throws IOException {\n        try (var p = capture(\"git\", \"branch\", name, hash.hex())) {\n            await(p);\n        }\n\n        return new Branch(name);\n    }\n\n    @Override\n    public void prune(Branch branch, String remote) throws IOException {\n        try (var p = capture(\"git\", \"push\", \"--delete\", remote, branch.name())) {\n            await(p);\n        }\n        try (var p = capture(\"git\", \"branch\", \"--delete\", \"--force\", branch.name())) {\n            await(p);\n        }\n    }\n\n    @Override\n    public Optional<Hash> mergeBaseOptional(Hash first, Hash second) throws IOException {\n        try (var p = capture(\"git\", \"merge-base\", first.hex(), second.hex())) {\n            var res = p.await();\n            if (res.status() == 1 && res.stdout().size() == 0) {\n                return Optional.empty();\n            }\n            if (res.status() != 0) {\n                throw new IOException(\"Unexpected exit code: \" + res);\n            }\n            if (res.stdout().size() != 1) {\n                 throw new IOException(\"Unexpected output\\n\" + res);\n            }\n            return Optional.of(new Hash(res.stdout().get(0)));\n        }\n    }\n\n    @Override\n    public Hash mergeBase(Hash first, Hash second) throws IOException {\n        return mergeBaseOptional(first, second)\n                .orElseThrow(() -> new IOException(\"Could not find merge-base between \" + first + \" and \" + second));\n    }\n\n    @Override\n    public boolean isAncestor(Hash ancestor, Hash descendant) throws IOException {\n        try (var p = capture(\"git\", \"merge-base\", \"--is-ancestor\", ancestor.hex(), descendant.hex())) {\n            var res = p.await();\n            return res.status() == 0;\n        }\n    }\n\n    @Override\n    public void rebase(Hash hash, String committerName, String committerEmail) throws IOException {\n        try (var p = Process.capture(\"git\", \"rebase\", \"--onto\", hash.hex(), \"--root\")\n                            .environ(\"GIT_COMMITTER_NAME\", committerName)\n                            .environ(\"GIT_COMMITTER_EMAIL\", committerEmail)\n                            .workdir(dir)\n                            .environ(currentEnv)\n                            .execute()) {\n            await(p);\n        }\n    }\n\n    @Override\n    public boolean isRemergeDiffEmpty(Hash mergeCommitHash) throws IOException {\n        // requires git 2.36 or newer\n        try (var p = Process.capture(\"git\", \"show\", \"--remerge-diff\", \"--format=%b\", mergeCommitHash.hex())\n                .workdir(dir)\n                .environ(currentEnv)\n                .execute()) {\n            return String.join(\"\", await(p).stdout()).isEmpty();\n        }\n    }\n\n    @Override\n    public Optional<Hash> resolve(String ref) throws IOException {\n        try (var p = capture(\"git\", \"rev-parse\", ref + \"^{commit}\")) {\n            var res = p.await();\n            if (res.status() == 0 && res.stdout().size() == 1) {\n                return Optional.of(new Hash(res.stdout().get(0)));\n            }\n            return Optional.empty();\n        }\n    }\n\n    @Override\n    public Optional<Branch> currentBranch() throws IOException {\n        try (var p = capture(\"git\", \"symbolic-ref\", \"--short\", \"HEAD\")) {\n            var res = p.await();\n            if (res.status() == 0 && res.stdout().size() == 1) {\n                return Optional.of(new Branch(res.stdout().get(0)));\n            }\n            return Optional.empty();\n        }\n    }\n\n    @Override\n    public Optional<Bookmark> currentBookmark() throws IOException {\n        throw new RuntimeException(\"git does not have bookmarks\");\n    }\n\n    @Override\n    public Branch defaultBranch() throws IOException {\n        try (var p = capture(\"git\", \"symbolic-ref\", \"--short\", \"refs/remotes/origin/HEAD\")) {\n            var res = p.await();\n            if (res.status() == 0 && res.stdout().size() == 1) {\n                var ref = res.stdout().get(0).substring(\"origin/\".length());\n                return new Branch(ref);\n            } else {\n                return Branch.defaultFor(VCS.GIT);\n            }\n        }\n    }\n\n    @Override\n    public Optional<Tag> defaultTag() throws IOException {\n        return Optional.empty();\n    }\n\n    @Override\n    public Optional<String> username() throws IOException {\n        var lines = config(\"user.name\");\n        return lines.size() == 1 ? Optional.of(lines.get(0)) : Optional.empty();\n    }\n\n    private Optional<String> email() throws IOException {\n        var lines = config(\"user.email\");\n        return lines.size() == 1 ? Optional.of(lines.get(0)) : Optional.empty();\n    }\n\n    private String treeEntry(Path path, Hash hash) throws IOException {\n        try (var p = Process.capture(\"git\", \"-c\", \"core.quotePath=false\", \"ls-tree\", hash.hex(), path.toString())\n                            .workdir(root())\n                            .execute()) {\n            var res = await(p);\n            if (res.stdout().size() == 0) {\n                return null;\n            }\n            if (res.stdout().size() > 1) {\n                throw new IOException(\"Unexpected output\\n\" + res);\n            }\n            return res.stdout().get(0);\n        }\n    }\n\n    private List<FileEntry> allFiles(Hash hash, List<Path> paths) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"-c\", \"core.quotePath=false\", \"ls-tree\", \"-r\"));\n        cmd.add(hash.hex());\n        cmd.addAll(paths.stream().map(Path::toString).collect(Collectors.toList()));\n        try (var p = Process.capture(cmd.toArray(new String[0]))\n                            .workdir(root())\n                            .execute()) {\n            var res = await(p);\n            var entries = new ArrayList<FileEntry>();\n            for (var line : res.stdout()) {\n                var parts = line.split(\"\\t\");\n                var metadata = parts[0].split(\" \");\n                var filename = parts[1];\n\n                var entry = new FileEntry(hash,\n                                          FileType.fromOctal(metadata[0]),\n                                          new Hash(metadata[2]),\n                                          Path.of(filename));\n                entries.add(entry);\n            }\n            return entries;\n        }\n    }\n\n    @Override\n    public List<FileEntry> files(Hash hash, List<Path> paths) throws IOException {\n        if (paths.isEmpty()) {\n            return allFiles(hash, paths);\n        }\n\n        var entries = new ArrayList<FileEntry>();\n        var batchSize = 64;\n        var start = 0;\n        while (start < paths.size()) {\n            var end = start + batchSize;\n            if (end > paths.size()) {\n                end = paths.size();\n            }\n            entries.addAll(allFiles(hash, paths.subList(start, end)));\n            start = end;\n        }\n        return entries;\n    }\n\n    private Path unpackFile(String blob) throws IOException {\n        try (var p = capture(\"git\", \"unpack-file\", blob)) {\n            var res = await(p);\n            if (res.stdout().size() != 1) {\n                throw new IOException(\"Unexpected output\\n\" + res);\n            }\n\n            return Path.of(root().toString(), res.stdout().get(0));\n        }\n    }\n\n    @Override\n    public Optional<byte[]> show(Path path, Hash hash) throws IOException {\n        var entries = files(hash, path);\n        if (entries.size() == 0) {\n            return Optional.empty();\n        } else if (entries.size() > 1) {\n            throw new IOException(\"Multiple files match path \" + path.toString() + \" in commit \" + hash.hex());\n        }\n\n        var entry = entries.get(0);\n        var type = entry.type();\n        if (type.isVCSLink()) {\n            var content = \"Subproject commit \" + entry.hash().hex() + \" \" + entry.path().toString();\n            return Optional.of(content.getBytes(StandardCharsets.UTF_8));\n        } else if (type.isRegular()) {\n            var tmp = unpackFile(entry.hash().hex());\n            var content = Files.readAllBytes(tmp);\n            Files.delete(tmp);\n            return Optional.of(content);\n        }\n\n        return Optional.empty();\n    }\n\n    @Override\n    public void dump(FileEntry entry, Path to) throws IOException {\n        var type = entry.type();\n        if (type.isRegular()) {\n            var path = unpackFile(entry.hash().hex());\n            Files.createDirectories(to.getParent());\n            Files.move(path, to, StandardCopyOption.REPLACE_EXISTING);\n        }\n    }\n\n    @Override\n    public List<StatusEntry> status(Hash from, Hash to) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"-c\", \"core.quotePath=false\", \"diff\", \"--raw\",\n                                          \"--find-renames=90%\",\n                                          \"--find-copies=90%\",\n                                          \"--find-copies-harder\",\n                                          \"--no-abbrev\",\n                                          \"--no-color\"));\n        if (from != null) {\n            if (from.equals(Hash.zero())) {\n                cmd.add(EMPTY_TREE.hex());\n            } else {\n                cmd.add(from.hex());\n            }\n        }\n        if (to != null) {\n            cmd.add(to.hex());\n        }\n        try (var p = capture(cmd)) {\n            var res = await(p);\n            var entries = new ArrayList<StatusEntry>();\n            for (var line : res.stdout()) {\n                entries.add(StatusEntry.fromRawLine(line));\n            }\n            return entries;\n        }\n    }\n\n    @Override\n    public List<StatusEntry> status() throws IOException {\n        return status(null, null);\n    }\n\n    @Override\n    public Diff diff(Hash from, int similarity) throws IOException {\n        return diff(from, List.of(), similarity);\n    }\n\n    @Override\n    public Diff diff(Hash from, List<Path> files, int similarity) throws IOException {\n        return diff(from, null, files, similarity);\n    }\n\n    @Override\n    public Diff diff(Hash from, Hash to, int similarity) throws IOException {\n        return diff(from, to, List.of(), similarity);\n    }\n\n    @Override\n    public Diff diff(Hash from, Hash to, List<Path> files, int similarity) throws IOException {\n        if (similarity < 0 || similarity > 100) {\n            throw new IllegalArgumentException(\"similarity must be between 0 and 100, is: \"  + similarity);\n        }\n        var cmd = new ArrayList<>(List.of(\"git\", \"-c\", \"core.quotePath=false\", \"diff\", \"--patch\",\n                                                         \"--find-renames=\" + similarity + \"%\",\n                                                         \"--find-copies=\" + similarity + \"%\",\n                                                         \"--find-copies-harder\",\n                                                         \"--binary\",\n                                                         \"--raw\",\n                                                         \"--no-abbrev\",\n                                                         \"--unified=0\",\n                                                         \"--no-color\"));\n        if (from != null) {\n            if (from.equals(Hash.zero())) {\n                cmd.add(EMPTY_TREE.hex());\n            } else {\n                cmd.add(from.hex());\n            }\n        }\n        if (to != null) {\n            cmd.add(to.hex());\n        }\n\n        if (files != null && !files.isEmpty()) {\n            cmd.add(\"--\");\n            for (var file : files) {\n                cmd.add(file.toString());\n            }\n        }\n\n        var p = start(cmd);\n        try {\n            var patches = GitRawDiffParser.parse(p.getInputStream());\n            await(p);\n            return new Diff(from, to, patches);\n        } catch (Throwable t) {\n            stop(p);\n            throw t;\n        }\n    }\n\n    @Override\n    public List<String> config(String key) throws IOException {\n        // We must explicitly do this *with* the user's .gitconfig, so override NO_CONFIG_ENV\n        try (var p = capture(dir, Collections.emptyMap(), \"git\", \"config\", key)) {\n            var res = p.await();\n            return res.status() == 0 ? res.stdout() : List.of();\n        }\n    }\n\n    @Override\n    public Hash head() throws IOException {\n        return resolve(\"HEAD\").orElseThrow(() -> new IllegalStateException(\"HEAD ref is not present\"));\n    }\n\n    public static Optional<Repository> get(Path p) throws IOException {\n        if (!Files.exists(p)) {\n            return Optional.empty();\n        }\n\n        var r = new GitRepository(p);\n        return r.exists() ? Optional.of(new GitRepository(r.root())) : Optional.empty();\n    }\n\n    @Override\n    public Repository copyTo(Path destination) throws IOException {\n        try (var p = capture(\"git\", \"clone\", \"--recurse-submodules\", root().toString(), destination.toString())) {\n            await(p);\n        }\n\n        return new GitRepository(destination);\n    }\n\n    @Override\n    public void merge(Hash h, FastForward ff) throws IOException {\n        merge(h.hex(), null, ff);\n    }\n\n    @Override\n    public void merge(Branch b, FastForward ff) throws IOException {\n        merge(b.name(), null, ff);\n    }\n\n    @Override\n    public void merge(Hash h, String strategy, FastForward ff) throws IOException {\n        merge(h.hex(), strategy, ff);\n    }\n\n    private void merge(String ref, String strategy, FastForward ff) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"-c\", \"user.name=unused\", \"-c\", \"user.email=unused\",\n                           \"merge\", \"--no-commit\"));\n\n        if (ff == FastForward.AUTO) {\n            cmd.add(\"--ff\");\n        } else if (ff == FastForward.DISABLE) {\n            cmd.add(\"--no-ff\");\n        } else if (ff == FastForward.ONLY) {\n            cmd.add(\"--ff-only\");\n        } else {\n            throw new IllegalArgumentException(\"Unexpected fast forward value: \" + ff);\n        }\n\n        if (strategy != null) {\n            cmd.add(\"-s\");\n            cmd.add(strategy);\n        }\n\n        cmd.add(ref);\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void abortMerge() throws IOException {\n        try (var p = capture(\"git\", \"merge\", \"--abort\")) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void addRemote(String name, String pullPath) throws IOException {\n        try (var p = capture(\"git\", \"remote\", \"add\", name, pullPath)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void setPaths(String remote, String pullPath, String pushPath) throws IOException {\n        pullPath = pullPath == null ? \"\" : pullPath;\n        try (var p = capture(\"git\", \"config\", \"remote.\" + remote + \".url\", pullPath)) {\n            await(p);\n        }\n\n        pushPath = pushPath == null ? \"\" : pushPath;\n        try (var p = capture(\"git\", \"config\", \"remote.\" + remote + \".pushurl\", pushPath)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public String pullPath(String remote) throws IOException {\n        var lines = config(\"remote.\" + remote + \".url\");\n        if (lines.size() != 1) {\n            throw new IOException(\"No pull path found for remote \" + remote);\n        }\n        return lines.get(0);\n    }\n\n    @Override\n    public String pushPath(String remote) throws IOException {\n        var lines = config(\"remote.\" + remote + \".pushurl\");\n        if (lines.size() != 1) {\n            return pullPath(remote);\n        }\n        return lines.get(0);\n    }\n\n    @Override\n    public boolean isValidRevisionRange(String expression) throws IOException {\n        try (var p = capture(\"git\", \"rev-parse\", expression)) {\n            return p.await().status() == 0;\n        }\n    }\n\n    private void applyPatch(Patch patch) throws IOException {\n        if (patch.isEmpty()) {\n            return;\n        }\n\n        if (patch.isTextual()) {\n        } else {\n            throw new IllegalArgumentException(\"Cannot handle binary patches yet\");\n        }\n    }\n\n    @Override\n    public void apply(Diff diff, boolean force) throws IOException {\n        // ignore force, no such concept in git\n        var patchFile = Files.createTempFile(\"apply\", \".patch\");\n        diff.toFile(patchFile);\n        apply(patchFile, force);\n        Files.delete(patchFile);\n    }\n\n    @Override\n    public void apply(Path patchFile, boolean force)  throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"apply\", \"--index\", \"--unidiff-zero\"));\n        cmd.add(patchFile.toAbsolutePath().toString());\n        try (var p = capture(cmd)) {\n            await(p);\n            Files.delete(patchFile);\n        }\n    }\n\n    @Override\n    public void copy(Path from, Path to) throws IOException {\n        Files.copy(from, to);\n        add(to);\n    }\n\n    @Override\n    public void move(Path from, Path to) throws IOException {\n        try (var p = capture(\"git\", \"mv\", from.toString(), to.toString())) {\n            await(p);\n        }\n    }\n\n    @Override\n    public Optional<String> upstreamFor(Branch b) throws IOException {\n        try (var p = capture(\"git\", \"for-each-ref\", \"--format=%(upstream:short)\", \"refs/heads/\" + b.name())) {\n            var lines = await(p).stdout();\n            return lines.size() == 1 && !lines.get(0).isEmpty()? Optional.of(lines.get(0)) : Optional.empty();\n        }\n    }\n\n    public static Repository clone(URI from, Path to, boolean isBare, Path seed) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"clone\"));\n        if (isBare) {\n            cmd.add(\"--bare\");\n        } else {\n            cmd.add(\"--recurse-submodules\");\n        }\n        if (seed != null) {\n            cmd.add(\"--reference-if-able\");\n            cmd.add(seed.toString());\n            // It's not safe to keep an alternates pointer back to the seed repo as we sometimes\n            // delete objects, which will cause clones to become corrupt.\n            cmd.add(\"--dissociate\");\n        }\n        cmd.addAll(List.of(from.toString(), to.toString()));\n        try (var p = capture(Path.of(\"\").toAbsolutePath(), cmd)) {\n            await(p);\n        }\n        return new GitRepository(to);\n    }\n\n    public static Repository mirror(URI from, Path to) throws IOException {\n        var cwd = Path.of(\"\").toAbsolutePath();\n        try (var p = capture(cwd, \"git\", \"clone\", \"--mirror\", from.toString(), to.toString())) {\n            await(p);\n        }\n        return new GitRepository(to);\n    }\n\n    @Override\n    public void pull(boolean includeTags) throws IOException {\n        pull(null, null, includeTags);\n    }\n\n    @Override\n    public void pull(String remote, boolean includeTags) throws IOException {\n        pull(remote, null, includeTags);\n    }\n\n\n    @Override\n    public void pull(String remote, String refspec, boolean includeTags) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.add(\"git\");\n        cmd.add(\"pull\");\n        cmd.add(\"--recurse-submodules\");\n        if (includeTags) {\n            cmd.add(\"--tags\");\n        } else {\n            cmd.add(\"--no-tags\");\n        }\n        if (remote != null) {\n            cmd.add(remote);\n        }\n        if (refspec != null) {\n            cmd.add(refspec);\n        }\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public boolean contains(Branch b, Hash h) throws IOException {\n        try (var p = capture(\"git\", \"for-each-ref\", \"--contains\", h.hex(), \"--format\", \"%(refname:short)\")) {\n            var res = await(p);\n            for (var line : res.stdout()) {\n                if (line.equals(b.name())) {\n                    return true;\n                }\n            }\n        }\n\n        return false;\n    }\n\n    @Override\n    public List<Reference> remoteBranches(String remote) throws IOException {\n        var refs = new ArrayList<Reference>();\n        try (var p = capture(\"git\", \"ls-remote\", \"--heads\", \"--refs\", remote)) {\n            for (var line : await(p).stdout()) {\n                var parts = line.split(\"\\t\");\n                var name = parts[1].replace(\"refs/heads/\", \"\");\n                refs.add(new Reference(name, new Hash(parts[0])));\n            }\n        }\n        return refs;\n    }\n\n    @Override\n    public List<String> remotes() throws IOException {\n        var remotes = new ArrayList<String>();\n        try (var p = capture(\"git\", \"remote\")) {\n            for (var line : await(p).stdout()) {\n                remotes.add(line);\n            }\n        }\n        return remotes;\n    }\n\n    @Override\n    public void updateSubmodule(Path path) throws IOException {\n        try (var p = capture(\"git\", \"submodule\", \"update\", path.toString())) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void addSubmodule(String pullPath, Path path) throws IOException {\n        try (var p = capture(\"git\", \"-c\", \"protocol.file.allow=always\", \"submodule\", \"add\", pullPath, path.toString())) {\n            await(p);\n        }\n    }\n\n    @Override\n    public List<Submodule> submodules() throws IOException {\n        var gitModules = root().resolve(\".gitmodules\");\n        if (!Files.exists(gitModules)) {\n            return List.of();\n        }\n\n        var urls = new HashMap<String, String>();\n        var paths = new HashMap<String, String>();\n        try (var p = capture(\"git\", \"config\", \"--file\", gitModules.toAbsolutePath().toString(),\n                                              \"--list\")) {\n            for (var line : await(p).stdout()) {\n                if (line.startsWith(\"submodule.\")) {\n                    line = line.substring(\"submodule.\".length());\n                    var parts = line.split(\"=\");\n                    var nameAndProperty = parts[0].split(\"\\\\.\");\n                    var name = nameAndProperty[0];\n                    var prop = nameAndProperty[1];\n                    var value = parts[1];\n                    if (prop.equals(\"path\")) {\n                        paths.put(name, value);\n                    } else if (prop.equals(\"url\")) {\n                        urls.put(name, value);\n                    } else {\n                        throw new IOException(\"Unexpected submodule property: \" + prop);\n                    }\n                }\n            }\n        }\n\n        var hashes = new HashMap<String, String>();\n        try (var p = capture(\"git\", \"submodule\", \"status\")) {\n            for (var line : await(p).stdout()) {\n                var parts = line.substring(1).split(\" \");\n                var hash = parts[0];\n                var path = parts[1];\n                hashes.put(path, hash);\n            }\n        }\n\n        var modules = new ArrayList<Submodule>();\n        for (var name : paths.keySet()) {\n            var url = urls.get(name);\n            var path = paths.get(name);\n            var hash = hashes.get(path);\n\n            modules.add(new Submodule(new Hash(hash), Path.of(path), url));\n        }\n\n        return modules;\n    }\n\n    @Override\n    public Tree tree(Hash h) throws IOException {\n        String treeHash;\n        try (var p = capture(\"git\", \"cat-file\", \"-p\", h.hex())) {\n            var res = p.await();\n            if (res.stdout().size() > 0) {\n                var line = res.stdout().get(0);\n                if (line.startsWith(\"tree \")) {\n                    treeHash = line.substring(5).trim();\n                    if (treeHash.length() != 40) {\n                        throw new IOException(\"Unexpected output: \" + treeHash);\n                    }\n                } else {\n                    throw new IOException(\"Unexpected output: \" + line);\n                }\n            } else {\n                throw new IOException(\"Unexpected output: \" + res.stderr());\n            }\n        }\n        return new Tree(new Hash(treeHash));\n    }\n\n    @Override\n    public Optional<Tag.Annotated> annotate(Tag tag) throws IOException {\n        var ref = \"refs/tags/\" + tag.name();\n        var format = \"%(refname:short)%0a%(*objectname)%0a%(taggername) %(taggeremail)%0a%(taggerdate:iso-strict)%0a%(contents)\";\n        try (var p = capture(\"git\", \"for-each-ref\", \"--format\", format, ref)) {\n            var lines = await(p).stdout();\n            if (lines.size() >= 4) {\n                var name = lines.get(0);\n                var targetLine = lines.get(1);\n                var authorLine = lines.get(2);\n                var dateLine = lines.get(3);\n\n                if (targetLine.isEmpty() && authorLine.equals(\" \") && dateLine.isEmpty()) {\n                    // Must be a lightweight tag, no metadata present\n                    return Optional.empty();\n                }\n\n                var target = new Hash(targetLine);\n                var author = Author.fromString(authorLine);\n                var formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;\n                var date = ZonedDateTime.parse(dateLine, formatter);\n                var message = String.join(\"\\n\", lines.subList(4, lines.size() - 1)); // Git adds newline\n\n                return Optional.of(new Tag.Annotated(name, target, author, date, message));\n            }\n            return Optional.empty();\n        }\n    }\n\n    @Override\n    public void config(String section, String key, String value, boolean global) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"git\", \"config\"));\n        if (global) {\n            cmd.add(\"--global\");\n        }\n        cmd.add(section + \".\" + key);\n        cmd.add(value);\n        // We must explicitly do this *with* the user's .gitconfig, so override NO_CONFIG_ENV\n        try (var p = capture(dir, Collections.emptyMap(), cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public String range(Hash h) {\n        return h.hex() + \"^!\";\n    }\n\n    @Override\n    public String rangeInclusive(Hash from, Hash to) {\n        return from.hex() + \"^..\" + to.hex();\n    }\n\n    @Override\n    public String rangeExclusive(Hash from, Hash to) {\n        return from.hex() + \"..\" + to.hex();\n    }\n\n    @Override\n    public boolean cherryPick(Hash hash) throws IOException {\n        try (var p = capture(\"git\", \"cherry-pick\", \"--no-commit\",\n                                                   \"--keep-redundant-commits\",\n                                                   \"--strategy=recursive\",\n                                                   \"--strategy-option=patience\",\n                                                   hash.hex())) {\n            return p.await().status() == 0;\n        }\n    }\n\n    @Override\n    public int commitCount() throws IOException {\n        try (var p = capture(\"git\", \"rev-list\", \"--all\", \"--count\")) {\n            return Integer.parseInt(await(p).stdout().get(0));\n        }\n    }\n\n    @Override\n    public int commitCount(List<Branch> branches) throws IOException {\n        var args = new ArrayList<String>();\n        args.addAll(List.of(\"git\", \"rev-list\", \"--count\"));\n        args.addAll(branches.stream().map(Branch::name).toList());\n        try (var p = capture(args)) {\n            return Integer.parseInt(await(p).stdout().getFirst());\n        }\n    }\n\n    @Override\n    public Hash initialHash() {\n        return EMPTY_TREE;\n    }\n\n    @Override\n    public Optional<List<String>> stagedFileContents(Path path) {\n        try (var p = capture(\"git\", \"cat-file\", \"-p\", \":\" + path.toString())) {\n            var res = p.await();\n            if (res.status() == 0) {\n                return Optional.of(res.stdout());\n            }\n        }\n        return Optional.empty();\n    }\n\n    /**\n     * Creates a fake Commit instance representing the currently staged diff.\n     */\n    @Override\n    public Commit staged() throws IOException {\n        var author = new Author(username().orElse(\"jcheck\"), email().orElse(\"jcheck@none.none\"));\n        var commitMetaData = new CommitMetadata(new Hash(\"staged\"), List.of(head()), author, ZonedDateTime.now(),\n                author, ZonedDateTime.now(), List.of(\"Fake commit message for staged\"));\n        return new Commit(commitMetaData, List.of(diffStaged()));\n    }\n\n    /**\n     * Creates a fake Commit instance representing the current working tree.\n     */\n    @Override\n    public Commit workingTree() throws IOException {\n        var author = new Author(username().orElse(\"jcheck\"), email().orElse(\"jcheck@none.none\"));\n        var commitMetaData = new CommitMetadata(new Hash(\"working-tree\"), List.of(head()), author, ZonedDateTime.now(),\n                author, ZonedDateTime.now(), List.of(\"Fake commit message for working-tree\"));\n        return new Commit(commitMetaData, List.of(diff(head())));\n    }\n\n    private Diff diffStaged() throws IOException {\n        var cmd = new ArrayList<>(List.of(\"git\", \"-c\", \"core.quotePath=false\", \"diff\", \"--patch\", \"--cached\",\n                \"--find-renames=\" + \"90\" + \"%\",\n                \"--find-copies=\" + \"90\" + \"%\",\n                \"--find-copies-harder\",\n                \"--binary\",\n                \"--raw\",\n                \"--no-abbrev\",\n                \"--unified=0\",\n                \"--no-color\"));\n        cmd.add(head().hex());\n\n        var p = start(cmd);\n        try {\n            var patches = GitRawDiffParser.parse(p.getInputStream());\n            await(p);\n            return new Diff(head(), null, patches);\n        } catch (Throwable t) {\n            stop(p);\n            throw t;\n        }\n    }\n\n    @Override\n    public boolean isEmptyCommit(Hash hash) {\n        try (var p = capture(\"git\", \"show\", \"--cc\", \"--pretty=format:%b\", hash.hex())) {\n            var res = p.await();\n            if (res.status() != 0) {\n                return false;\n            }\n            var lines = res.stdout();\n            for (int i = 0; i < lines.size() - 1; i++) {\n                if (lines.get(i).startsWith(\"diff\") && lines.get(i + 1).startsWith(\"index\")) {\n                    return false;\n                }\n            }\n            return true;\n        }\n    }\n\n    @Override\n    public void addNote(Hash hash,\n                        List<String> lines,\n                        String authorName,\n                        String authorEmail,\n                        String committerName,\n                        String committerEmail) throws IOException {\n        var existing = notes(hash);\n        if (!existing.isEmpty()) {\n            throw new IllegalStateException(\"A note already exists for \" + hash.hex());\n        }\n\n        var cmd = Process.capture(\"git\", \"notes\", \"add\", \"-m\", String.join(\"\\n\", lines), hash.hex())\n                         .workdir(dir)\n                         .environ(currentEnv)\n                         .environ(\"GIT_AUTHOR_NAME\", authorName)\n                         .environ(\"GIT_AUTHOR_EMAIL\", authorEmail)\n                         .environ(\"GIT_COMMITTER_NAME\", committerName)\n                         .environ(\"GIT_COMMITTER_EMAIL\", committerEmail);\n        try (var p = cmd.execute()) {\n            await(p);\n        }\n    }\n\n    @Override\n    public List<String> notes(Hash hash) throws IOException {\n        try (var p = capture(\"git\", \"notes\", \"show\", hash.hex())) {\n            var res = p.await();\n            if (res.status() != 0) {\n                return List.of();\n            }\n            return res.stdout();\n        }\n    }\n\n    @Override\n    public void pushNotes(URI uri) throws IOException {\n        push(\"refs/notes/*:refs/notes/*\", uri);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/git/GitVersion.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.git;\n\nimport java.io.BufferedReader;\nimport java.io.IOException;\nimport java.io.InputStreamReader;\nimport java.util.Objects;\nimport java.util.regex.Pattern;\nimport java.util.stream.Collectors;\n\npublic class GitVersion {\n\n    private static final Pattern versionPattern = Pattern.compile(\n            \"git version (?<versionString>.*?(?<major>\\\\d+)\\\\.(?<minor>\\\\d+)\\\\.(?<security>\\\\d+).*)\");\n    private static final GitVersion UNKNOWN = new GitVersion(\"UNKNOWN\", -1, -1, -1);\n\n    private final String versionString;\n    private final int major;\n    private final int minor;\n    private final int security;\n\n    private GitVersion(String versionString, int major, int minor, int security) {\n        this.versionString = versionString;\n        this.major = major;\n        this.minor = minor;\n        this.security = security;\n    }\n\n    public static GitVersion parse(String version) {\n        var matcher = versionPattern.matcher(version);\n        if (!matcher.find()) {\n            return UNKNOWN;\n        }\n\n        return new GitVersion(\n            matcher.group(\"versionString\"),\n            Integer.parseInt(matcher.group(\"major\")),\n            Integer.parseInt(matcher.group(\"minor\")),\n            Integer.parseInt(matcher.group(\"security\"))\n        );\n    }\n\n    public static GitVersion get() throws IOException {\n        var p = new ProcessBuilder().command(\"git\", \"--version\").start();\n        try {\n            var code = p.waitFor();\n            if (code != 0) throw new IOException(\"git --version exited with code: \" + code);\n            try (var lines = new BufferedReader(new InputStreamReader(p.getInputStream())).lines()) {\n                var linesList = lines.collect(Collectors.toList());\n                for (var line : linesList) {\n                    var version = parse(line);\n                    if (version != UNKNOWN) {\n                        return version;\n                    }\n                }\n            }\n            return UNKNOWN;\n        } catch (InterruptedException e) {\n            throw new IOException(e);\n        }\n    }\n\n    public boolean isKnownSupported() {\n        if (major < 2) {\n            return false;\n        }\n\n        switch (minor) {\n//            case 17:\n//            case 19:\n//                return security >= 4;\n//\n//            case 18:\n//            case 20:\n            case 22: // we require 2.22 since we use --combined-all-paths option of git log\n            case 25:\n                return security >= 3;\n\n//            case 21:\n            case 23:\n            case 24:\n                return security >= 2;\n\n            default: {\n                if (minor >= 26) {\n                    return true;\n                }\n            }\n        }\n\n        return false;\n    }\n\n    public boolean isUnknown() {\n        return this == UNKNOWN;\n    }\n\n    public int major() {\n        return major;\n    }\n\n    public int minor() {\n        return minor;\n    }\n\n    public int security() {\n        return security;\n    }\n\n    @Override\n    public String toString() {\n        return versionString;\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) return true;\n        if (o == null || getClass() != o.getClass()) return false;\n        GitVersion that = (GitVersion) o;\n        return Objects.equals(versionString, that.versionString);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(versionString);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/hg/HgCommitIterator.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.hg;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.tools.*;\n\nimport java.io.*;\nimport java.nio.charset.StandardCharsets;\nimport java.util.*;\nimport java.time.*;\nimport java.time.format.DateTimeFormatter;\n\nclass HgCommitIterator implements Iterator<Commit> {\n    public static final String commitDelimiter = \"#@!_-=&\";\n    private final UnixStreamReader reader;\n    private String line;\n\n    public HgCommitIterator(UnixStreamReader reader) {\n        this.reader = reader;\n        try {\n            line = reader.readLine();\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public boolean hasNext() {\n        return line != null;\n    }\n\n    public Commit next() {\n        if (line == null) {\n            return null;\n        }\n\n        try {\n            if (!line.equals(commitDelimiter)) {\n                throw new IllegalStateException(\"Unexpected line: \" + line);\n            }\n\n            var metadata = HgCommitMetadata.read(reader);\n            line = reader.lastLine(); // update state for hasNext\n\n            var hash = metadata.hash();\n            var parents = metadata.parents();\n\n            List<Diff> parentDiffs = null;\n            if (metadata.parents().size() == 1) {\n                var patches = GitRawDiffParser.parse(reader, commitDelimiter);\n                parentDiffs = List.of(new Diff(parents.get(0), hash, patches));\n            } else {\n                var p0 = GitRawDiffParser.parse(reader, commitDelimiter);\n                var p1 = GitRawDiffParser.parse(reader, commitDelimiter);\n                parentDiffs = List.of(new Diff(parents.get(0), hash, p0),\n                                      new Diff(parents.get(1), hash, p1));\n            }\n            line = reader.lastLine(); // update state for hasNext\n\n            return new Commit(metadata, parentDiffs);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n}\n\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/hg/HgCommitMetadata.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.hg;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.tools.*;\n\nimport java.io.IOException;\nimport java.util.*;\nimport java.time.*;\nimport java.time.format.DateTimeParseException;\nimport java.time.format.*;\nimport java.nio.charset.StandardCharsets;\n\nclass HgCommitMetadata {\n    private static final String delimiter = \"#@!_-=&\";\n    private static final String hash = \"{node}\";\n    private static final String rev = \"{rev}\";\n    private static final String branch = \"{branch}\";\n    private static final String parentHashes = \"{p1.node} {p2.node}\";\n    private static final String parentRevs = \"{p1.rev} {p2.rev}\";\n    private static final String user = \"{user}\";\n    private static final String date = \"{date|rfc3339date}\";\n    private static final String descLen = \"{desc|count}\";\n    private static final String desc = \"{desc}\";\n\n    public static final String TEMPLATE = String.join(\"\\n\",\n                                                      delimiter,\n                                                      hash,\n                                                      rev,\n                                                      branch,\n                                                      parentHashes,\n                                                      parentRevs,\n                                                      user,\n                                                      date,\n                                                      descLen,\n                                                      desc);\n    public static CommitMetadata read(UnixStreamReader reader) throws IOException {\n        var hash = new Hash(reader.readLine());\n\n        reader.readLine(); // skip revision number\n        reader.readLine(); // skip branch name\n\n        var parents = new ArrayList<Hash>();\n        for (var parentHash : reader.readLine().split(\" \")) {\n            parents.add(new Hash(parentHash));\n        }\n        reader.readLine(); // skip revision numbers for parents\n\n        var author = Author.fromString(reader.readLine());\n\n        // ext.py and hg uses slightly different time formats\n        ZonedDateTime authored = null;\n        var date = reader.readLine();\n        try {\n            // ext.py\n            var formatter = DateTimeFormatter.ofPattern(\"yyyy-MM-dd H:m:sZ\");\n            authored = ZonedDateTime.parse(date, formatter);\n        } catch (DateTimeParseException e) {\n            // hg's rfc3339date\n            authored = ZonedDateTime.parse(date, DateTimeFormatter.ISO_OFFSET_DATE_TIME);\n        }\n\n        var messageSize = Integer.parseInt(reader.readLine());\n        var messageBuffer = reader.read(messageSize);\n        var message = new ArrayList<String>();\n        var last = -1;\n        for (var i = 0; i < messageSize; i++) {\n            var offset = last + 1;\n            if (messageBuffer[i] == (byte) '\\n') {\n                message.add(new String(messageBuffer, offset, i - offset, StandardCharsets.UTF_8));\n                last = i;\n            } else if (i == (messageSize - 1)) {\n                // the last character wasn't newline, add the rest\n                message.add(new String(messageBuffer, offset, messageSize - offset, StandardCharsets.UTF_8));\n            }\n        }\n\n        return new CommitMetadata(hash, parents, author, authored, author, authored, message);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/hg/HgCommits.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.hg;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.tools.*;\n\nimport java.util.concurrent.TimeUnit;\nimport java.io.*;\nimport java.util.*;\nimport java.nio.file.Path;\nimport java.util.logging.Logger;\n\nclass HgCommits implements Commits, AutoCloseable {\n    private final List<Process> processes = new ArrayList<>();\n    private final List<List<String>> commands = new ArrayList<>();;\n    private final Path dir;\n    private final String range;\n    private final String ext;\n    private final boolean reverse;\n    private final int num;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.vcs.hg\");\n\n    private boolean closed = false;\n\n    public HgCommits(Path dir, String range, Path ext, boolean reverse, int num) throws IOException {\n        this.dir = dir;\n        this.range = range;\n        this.ext = ext.toAbsolutePath().toString();\n        this.reverse = reverse;\n        this.num = num;\n    }\n\n    @Override\n    public Iterator<Commit> iterator() {\n        var command = new ArrayList<>(List.of(\"hg\", \"--config\", \"extensions.log-git=\" + ext, \"log-git\"));\n        if (reverse) {\n            command.add(\"--reverse\");\n        }\n        if (num > 0) {\n            command.add(\"--limit\");\n            command.add(Integer.toString(num));\n        }\n        if (range != null) {\n            command.add(range);\n        }\n\n        var pb = new ProcessBuilder(command);\n        pb.directory(dir.toFile());\n        pb.redirectError(ProcessBuilder.Redirect.DISCARD);\n        pb.environment().put(\"HGRCPATH\", \"\");\n        pb.environment().put(\"HGPLAIN\", \"\");\n\n        try {\n            log.fine(\"Executing \" + String.join(\" \", command));\n            var p = pb.start();\n            processes.add(p);\n            commands.add(command);\n\n            var reader = new UnixStreamReader(p.getInputStream());\n            return new HgCommitIterator(reader);\n        } catch (IOException e) {\n            throw new UncheckedIOException(e);\n        }\n    }\n\n    @Override\n    public void close() throws IOException {\n        synchronized (this) {\n            if (!closed) {\n                closed = true;\n            } else {\n                return;\n            }\n        }\n\n        for (var i = 0; i < processes.size(); i++) {\n            var p = processes.get(i);\n            var command = commands.get(i);\n            try {\n                var exited = p.waitFor(30L, TimeUnit.SECONDS);\n                if (!exited) {\n                    throw new IOException(\"'\" + String.join(\" \", command) + \"' timed out\");\n                }\n                var exitCode = p.exitValue();\n                if (exitCode != 0) {\n                    throw new IOException(\"'\" + String.join(\" \", command) + \"' exited with code: \" + exitCode);\n                }\n            } catch (InterruptedException e) {\n                throw new IOException(\"'\" + String.join(\" \", command) + \"' was interrupted\", e);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/hg/HgRepository.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.hg;\n\nimport org.openjdk.skara.process.Process;\nimport org.openjdk.skara.process.Execution;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.tools.*;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.time.*;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\nimport java.util.logging.Logger;\nimport java.util.stream.*;\nimport java.net.URI;\n\npublic class HgRepository implements Repository {\n    private final static Map<String, String> NO_CONFIG_ENV = Map.of(\n            \"HGRCPATH\", \"\",\n            \"HGPLAIN\", \"\",\n            \"HGEDITOR\", \"\",\n            \"EDITOR\", \"\",\n            \"VISUAL\", \"\"\n    );\n    private static Map<String, String> currentEnv = Collections.emptyMap();\n\n    private static final String EXT_PY = \"ext.py\";\n    private final Path dir;\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.vcs.hg\");\n\n    private static final Hash NULL_REVISION = new Hash(\"0\".repeat(40));\n\n    public static void ignoreConfiguration() {\n        currentEnv = NO_CONFIG_ENV;\n    }\n\n    @Override\n    public boolean isRemergeDiffEmpty(Hash mergeCommitHash) throws IOException {\n        throw new UnsupportedOperationException();\n    }\n\n    private void copyResource(String name, Path p) throws IOException {\n        Files.copy(this.getClass().getResourceAsStream(\"/\" + name), p, StandardCopyOption.REPLACE_EXISTING);\n    }\n\n    private java.lang.Process start(String... cmd) throws IOException {\n        return start(Arrays.asList(cmd));\n    }\n\n    private java.lang.Process start(List<String> cmd) throws IOException {\n        log.fine(\"Executing \" + String.join(\" \", cmd));\n        var pb = new ProcessBuilder(cmd);\n        pb.directory(dir.toFile());\n        pb.redirectError(ProcessBuilder.Redirect.DISCARD);\n        pb.environment().putAll(currentEnv);\n        return pb.start();\n    }\n\n    private static void stop(java.lang.Process p) throws IOException {\n        if (p != null && p.isAlive()) {\n            var stream = p.getInputStream();\n            var read = 0;\n            var buf = new byte[128];\n            while (read != -1) {\n                read = stream.read(buf);\n            }\n            try {\n                p.waitFor();\n            } catch (InterruptedException e) {\n                throw new IOException(e);\n            }\n        }\n    }\n\n    private Execution capture(List<String> cmd) {\n        return capture(cmd.toArray(new String[0]));\n    }\n\n    private Execution capture(String... cmd) {\n        return capture(dir, cmd);\n    }\n\n    private static Execution capture(Path cwd, List<String> cmd) {\n        return capture(cwd, cmd.toArray(new String[0]));\n    }\n    public static Execution capture(Path cwd, String... cmd) {\n        return Process.capture(cmd)\n                      .environ(currentEnv)\n                      .workdir(cwd)\n                      .execute();\n    }\n\n    private static Execution.Result await(Execution e) throws IOException {\n        var result = e.await();\n        if (result.status() != 0) {\n            if (result.exception().isPresent()) {\n                throw new IOException(\"Unexpected exit code\\n\" + result, result.exception().get());\n            } else {\n                throw new IOException(\"Unexpected exit code\\n\" + result);\n            }\n        }\n        return result;\n    }\n\n    private static void await(java.lang.Process p) throws IOException {\n        try {\n            var res = p.waitFor();\n            if (res != 0) {\n                throw new IOException(\"Unexpected exit code: \" + res);\n            }\n        } catch (InterruptedException e) {\n            throw new IOException(e);\n        }\n    }\n\n    public HgRepository(Path dir) {\n        this.dir = dir.toAbsolutePath();\n    }\n\n    @Override\n    public List<Branch> branches() throws IOException {\n        try (var p = capture(\"hg\", \"branches\")) {\n            return await(p).stdout()\n                           .stream()\n                           .map(line -> line.split(\"\\\\s\")[0])\n                           .map(Branch::new)\n                           .collect(Collectors.toList());\n        }\n    }\n\n    @Override\n    public List<Branch> branches(String remote) throws IOException {\n        // Mercurial does not have namespacing of branch names\n        return branches();\n    }\n\n    @Override\n    public List<Tag> tags() throws IOException {\n        try (var p = capture(\"hg\", \"tags\")) {\n            return await(p).stdout()\n                           .stream()\n                           .map(line -> line.split(\"\\\\s\")[0])\n                           .map(Tag::new)\n                           .collect(Collectors.toList());\n        }\n    }\n\n    @Override\n    public Path root() throws IOException {\n        try (var p = capture(\"hg\", \"root\")) {\n            var res = await(p);\n            if (res.stdout().size() != 1) {\n                throw new IOException(\"Unexpected output\\n\" + res);\n            }\n            return Paths.get(res.stdout().get(0));\n        }\n    }\n\n    private void checkout(String ref, boolean force) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"hg\", \"update\"));\n        if (!force) {\n            cmd.add(\"--check\");\n        }\n        cmd.add(ref);\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void checkout(Hash h, boolean force) throws IOException {\n        checkout(h.hex(), force);\n    }\n\n    @Override\n    public void checkout(Branch b, boolean force) throws IOException {\n        checkout(b.name(), force);\n    }\n\n    @Override\n    public Optional<Hash> resolve(String ref) throws IOException {\n        try (var p = capture(\"hg\", \"--config\", \"defaults.log=\", \"log\", \"--rev=\" + ref, \"--template={node}\\n\")) {\n            var res = p.await();\n            if (res.status() == 0 && res.stdout().size() == 1) {\n                return Optional.of(new Hash(res.stdout().get(0)));\n            }\n            return Optional.empty();\n        }\n    }\n\n    @Override\n    public Commits commits() throws IOException {\n        return commits(null, -1, false);\n    }\n\n    @Override\n    public Commits commits(boolean reverse) throws IOException {\n        return commits(null, -1, reverse);\n    }\n\n    @Override\n    public Commits commits(int n) throws IOException {\n        return commits(null, n, false);\n    }\n\n    @Override\n    public Commits commits(int n, boolean reverse) throws IOException {\n        return commits(null, n, reverse);\n    }\n\n    @Override\n    public Commits commits(String range) throws IOException {\n        return commits(range, -1, false);\n    }\n\n    @Override\n    public Commits commits(String range, int n) throws IOException {\n        return commits(range, n, false);\n    }\n\n    @Override\n    public Commits commits(String range, boolean reverse) throws IOException {\n        return commits(range, -1, reverse);\n    }\n\n    @Override\n    public Commits commits(String range, int n,  boolean reverse) throws IOException {\n        var ext = Files.createTempFile(\"ext\", \".py\");\n        copyResource(EXT_PY, ext);\n        return new HgCommits(dir, range, ext, reverse, n);\n    }\n\n    @Override\n    public Commits commits(List<Hash> reachableFrom, List<Hash> unreachableFrom) throws IOException {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public boolean contains(Hash h) throws IOException {\n        try (var p = capture(\"hg\", \"log\", \"--rev=\" + h.hex(), \"--template={node}\\n\")) {\n            var res = p.await();\n            return res.status() == 0;\n        }\n    }\n\n    @Override\n    public Optional<Commit> lookup(Hash h) throws IOException {\n        if (!contains(h)) {\n            return Optional.empty();\n        }\n        var commits = commits(h.hex()).asList();\n        if (commits.size() != 1) {\n            return Optional.empty();\n        }\n        return Optional.of(commits.get(0));\n    }\n\n    @Override\n    public Optional<Commit> lookup(Branch b) throws IOException {\n        var hash = resolve(b.name()).orElseThrow(() -> new IOException(\"Branch \" + b.name() + \" not found\"));\n        return lookup(hash);\n    }\n\n    @Override\n    public Optional<Commit> lookup(Tag t) throws IOException {\n        var hash = resolve(t.name()).orElseThrow(() -> new IOException(\"Tag \" + t.name() + \" not found\"));\n        return lookup(hash);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(String range, List<Path> paths) throws IOException {\n        return commitMetadata(range, paths, false);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(Hash from, Hash to, List<Path> paths) throws IOException {\n        return commitMetadata(from.hex() + \":\" + to.hex() + \"-\" + from.hex(), paths, false);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(Hash from, Hash to, List<Path> paths, boolean reverse) throws IOException {\n        return commitMetadata(from.hex() + \":\" + to.hex() + \"-\" + from.hex(), paths, reverse);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(String range, List<Path> paths, boolean reverse) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"hg\", \"log\", \"--template\", HgCommitMetadata.TEMPLATE));\n        range = range == null ? \"tip:0\" : range;\n        var revset = reverse ? \"reverse(\" + range + \")\" : range;\n        cmd.add(\"--rev\");\n        cmd.add(revset);\n        if (paths != null && !paths.isEmpty()) {\n            cmd.addAll(paths.stream().map(Path::toString).collect(Collectors.toList()));\n        }\n        return readMetadata(cmd);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadataFor(List<Branch> branches) throws IOException {\n        throw new RuntimeException(\"Not implemented yet\");\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(String range) throws IOException {\n        return commitMetadata(range, List.of(), false);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(boolean reverse) throws IOException {\n        return commitMetadata(null, List.of(), reverse);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(Hash from, Hash to) throws IOException {\n        return commitMetadata(from.hex() + \":\" + to.hex() + \"-\" + from.hex(), List.of(), false);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(String range, boolean reverse) throws IOException {\n        return commitMetadata(range, List.of(), reverse);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(Hash from, Hash to, boolean reverse) throws IOException {\n        return commitMetadata(from.hex() + \":\" + to.hex() + \"-\" + from.hex(), reverse);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(List<Path> paths) throws IOException {\n        return commitMetadata(null, paths, false);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata(List<Path> paths, boolean reverse) throws IOException {\n        return commitMetadata(null, paths, reverse);\n    }\n\n    @Override\n    public List<CommitMetadata> commitMetadata() throws IOException {\n        return commitMetadata(null, List.of(), false);\n    }\n\n    @Override\n    public List<CommitMetadata> follow(Path path) throws IOException {\n        return follow(path, null, null);\n    }\n\n    private List<CommitMetadata> readMetadata(List<String> cmd) throws IOException {\n        var p = start(cmd);\n        var reader = new UnixStreamReader(p.getInputStream());\n        var result = new ArrayList<CommitMetadata>();\n\n        var line = reader.readLine();\n        while (line != null) {\n            result.add(HgCommitMetadata.read(reader));\n            line = reader.readLine();\n        }\n\n        await(p);\n        return result;\n    }\n\n    @Override\n    public List<CommitMetadata> follow(Path path, Hash from, Hash to) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"hg\", \"log\", \"--follow\", \"--template\", HgCommitMetadata.TEMPLATE));\n        if (from != null && to != null) {\n            cmd.add(\"--rev\");\n            cmd.add(from.hex() + \"..\" + to.hex() + \" - \" + from.hex());\n        }\n        cmd.add(path.toString());\n        return readMetadata(cmd);\n    }\n\n    @Override\n    public boolean isEmpty() throws IOException {\n        var numBranches = branches().size();\n        var numTags = tags().size();\n\n        if (numBranches > 0 || numTags > 1) {\n            return false;\n        }\n\n        var tip = resolve(\"tip\");\n        return tip.isEmpty() || tip.get().equals(Hash.zero());\n    }\n\n    @Override\n    public boolean isHealthy() throws IOException {\n        var root = root().toString();\n        return !(Files.exists(Path.of(root, \".hg\", \"wlock\")) ||\n                 Files.exists(Path.of(root, \".hg\", \"store\", \"lock\")));\n    }\n\n    @Override\n    public void deleteUntrackedFiles() throws IOException {\n        var root = root();\n        try (var p = capture(\"hg\", \"status\", \"--unknown\", \"--no-status\")) {\n            var res = await(p);\n            for (var line : res.stdout()) {\n                Files.delete(root.resolve(line));\n            }\n        }\n    }\n\n    @Override\n    public void clean() throws IOException {\n        try (var p = capture(\"hg\", \"merge\", \"--abort\")) {\n            p.await();\n        }\n\n        try (var p = capture(\"hg\", \"recover\")) {\n            p.await();\n        }\n\n        try (var p = capture(\"hg\", \"status\", \"--ignored\", \"--no-status\")) {\n            var root = root().toString();\n            for (var filename : await(p).stdout()) {\n                Files.delete(Path.of(root, filename));\n            }\n        }\n\n        try (var p = capture(\"hg\", \"status\", \"--unknown\", \"--no-status\")) {\n            var root = root().toString();\n            for (var filename : await(p).stdout()) {\n                Files.delete(Path.of(root, filename));\n            }\n        }\n\n        try (var p = capture(\"hg\", \"revert\", \"--no-backup\", \"--all\")) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void reset(Hash target, boolean hard) throws IOException {\n        throw new RuntimeException(\"Not implemented yet\");\n    }\n\n    @Override\n    public Repository reinitialize() throws IOException {\n        try (var paths = Files.walk(dir)) {\n            paths.map(Path::toFile)\n                 .sorted(Comparator.reverseOrder())\n                 .forEach(File::delete);\n        }\n\n        return init();\n    }\n\n    @Override\n    public Optional<Hash> fetch(URI uri, String refspec, boolean includeTags, boolean forceUpdateTags) throws IOException {\n        // Ignore includeTags and forceUpdateTags, Mercurial always fetches tags\n        return fetch(uri != null ? uri.toString() : null, refspec);\n    }\n\n    @Override\n    public void fetchAll(URI uri, boolean includeTags) throws IOException {\n        // Ignore includeTags, Mercurial always fetches tags\n        var cmd = new ArrayList<String>();\n        cmd.add(\"hg\");\n        cmd.add(\"pull\");\n        cmd.add(uri.toString());\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    private Optional<Hash> fetch(String from, String refspec) throws IOException {\n        var oldHeads = new HashSet<>(heads());\n\n        var cmd = new ArrayList<String>();\n        cmd.add(\"hg\");\n        cmd.add(\"pull\");\n        if (refspec != null) {\n            cmd.add(\"--rev\");\n            cmd.add(refspec);\n        }\n        if (from != null) {\n            cmd.add(from);\n        }\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n\n        var newHeads = new HashSet<>(heads());\n        newHeads.removeAll(oldHeads);\n\n        if (newHeads.size() > 1) {\n            throw new IllegalStateException(\"fetching multiple heads is not supported\");\n        } else if (newHeads.size() == 0) {\n            // no new head was fetched, return current head\n            return Optional.of(head());\n        }\n        return Optional.of(newHeads.iterator().next());\n    }\n\n    @Override\n    public void fetchAllRemotes(boolean includeTags) throws IOException {\n        // Ignore includeTags, Mercurial always fetches tags\n        var pullPaths = new ArrayList<URI>();\n        try (var p = capture(\"hg\", \"paths\")) {\n            var res = await(p);\n            for (var line : res.stdout()) {\n                var parts = line.split(\"=\");\n                var name = parts[0].trim();\n                var uri = parts[1].trim();\n                if (!name.endsWith(\"-push\")) {\n                    pullPaths.add(URI.create(uri));\n                }\n            }\n        }\n\n        for (var uri : pullPaths) {\n            fetch(uri, null);\n        }\n    }\n\n    @Override\n    public void fetchRemote(String remote) throws IOException {\n        fetch(remote, null);\n    }\n\n    @Override\n    public void delete(Branch b) throws IOException {\n        throw new RuntimeException(\"Branches cannot be deleted in Mercurial\");\n    }\n\n    @Override\n    public Repository init() throws IOException {\n        if (!Files.exists(dir)) {\n            Files.createDirectories(dir);\n        }\n\n        try (var p = capture(\"hg\", \"init\")) {\n            await(p);\n            return this;\n        }\n    }\n\n    @Override\n    public void pushAll(URI uri, boolean force) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"hg\", \"push\", \"--new-branch\"));\n        if (force) {\n            cmd.add(\"--force\");\n        }\n        cmd.add(uri.toString());\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void pushTags(URI uri, boolean force) throws IOException {\n        throw new RuntimeException(\"Cannot push only tags with Mercurial\");\n    }\n\n    @Override\n    public void push(Hash hash, URI uri, String ref, boolean force, boolean includeTags) throws IOException {\n        // ignore includeTags, hg always pushes tags\n        var cmd = new ArrayList<>(List.of(\"hg\", \"push\", \"--rev=\" + hash.hex()));\n        if (force) {\n            cmd.add(\"--force\");\n        }\n        cmd.add(uri.toString() + \"#\" + ref);\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void push(Branch branch, String remote, boolean setUpstream) throws IOException {\n        // ignore setUpstream, no such concept in Mercurial\n        try (var p = capture(\"hg\", \"push\", \"--branch\", branch.name(), remote)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void push(Tag tag, URI uri, boolean force) throws IOException {\n        var cmd = new ArrayList<>(List.of(\"hg\", \"push\"));\n        if (force) {\n            cmd.add(\"--force\");\n        }\n        cmd.add(tag.name());\n        cmd.add(uri.toString());\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void push(String refspec, URI uri) throws IOException {\n        throw new RuntimeException(\"Refspec not supported with Mercurial\");\n    }\n\n    @Override\n    public boolean isClean() throws IOException {\n        try (var p = capture(\"hg\", \"status\")) {\n            var output = await(p);\n            return output.stdout().size() == 0;\n        }\n    }\n\n    @Override\n    public boolean exists() throws IOException {\n        if (!Files.exists(dir)) {\n            return false;\n        }\n\n        try {\n            root();\n            return true;\n        } catch (IOException e) {\n            return false;\n        }\n    }\n\n    private void export(String revset, Path to) throws IOException {\n        var cmd = List.of(\"hg\", \"export\", \"--git\", \"--rev\", revset);\n        log.fine(\"Executing \" + String.join(\" \", cmd));\n        var pb = new ProcessBuilder(cmd);\n        pb.directory(dir.toFile());\n        pb.redirectError(ProcessBuilder.Redirect.DISCARD);\n        pb.redirectOutput(to.toFile());\n        pb.environment().putAll(currentEnv);\n        var p = pb.start();\n        try {\n            await(p);\n        } catch (Throwable t) {\n            if (p.isAlive()) {\n                try {\n                    p.waitFor();\n                } catch (InterruptedException e) {\n                    throw new IOException(e);\n                }\n            }\n\n            throw new IOException(t);\n        }\n    }\n\n    @Override\n    public void squash(Hash h) throws IOException {\n        var revset = \".:\" + h.hex() + \" and not .\";\n        var patch = Files.createTempFile(\"squash\", \".patch\");\n        export(revset, patch);\n\n        try (var p = capture(\"hg\", \"--config\", \"extensions.mq=\", \"strip\", \"--rev\", revset)) {\n            await(p);\n        }\n\n        try (var p = capture(\"hg\", \"import\", \"--no-commit\", patch.toString())) {\n            await(p);\n        }\n    }\n\n\n    @Override\n    public Hash commit(String message, String authorName, String authorEmail)  throws IOException {\n        return commit(message, authorName, authorEmail, null);\n    }\n\n    @Override\n    public Hash commit(String message, String authorName, String authorEmail, ZonedDateTime authorDate)  throws IOException {\n        var user = authorEmail == null ? authorName : authorName + \" <\" + authorEmail + \">\";\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"hg\", \"commit\", \"--message=\" + message, \"--user=\" + user));\n        if (authorDate != null) {\n            var formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;\n            cmd.add(\"--date=\" + authorDate.format(formatter));\n        }\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n        return resolve(\"tip\").orElseThrow(() -> new IOException(\"Could not resolve 'tip'\"));\n    }\n\n    @Override\n    public Hash commit(String message,\n                       String authorName,\n                       String authorEmail,\n                       String committerName,\n                       String committerEmail) throws IOException {\n        return commit(message, authorName, authorEmail, null, committerName, committerEmail, null);\n    }\n\n    @Override\n    public Hash commit(String message,\n                       String authorName,\n                       String authorEmail,\n                       ZonedDateTime authorDate,\n                       String committerName,\n                       String committerEmail,\n                       ZonedDateTime committerDate) throws IOException {\n        if (!Objects.equals(authorName, committerName) ||\n            !Objects.equals(authorEmail, committerEmail) ||\n            !Objects.equals(authorDate, committerDate)) {\n            throw new IllegalArgumentException(\"hg does not support different author and committer data\");\n        }\n\n        return commit(message, authorName, authorEmail, authorDate);\n    }\n\n    @Override\n    public Hash commit(String message, String authorName, String authorEmail, ZonedDateTime authorDate, String committerName, String committerEmail, ZonedDateTime committerDate, List<Hash> parents, Tree tree) throws IOException {\n        throw new RuntimeException(\"not implemented yet\");\n    }\n\n    @Override\n    public Hash amend(String message) throws IOException {\n        try (var p = capture(\"hg\", \"commit\", \"--amend\", \"--message=\" + message)) {\n            await(p);\n        }\n        return resolve(\"tip\").orElseThrow(() -> new IOException(\"Could not resolve 'tip'\"));\n    }\n\n    @Override\n    public Hash amend(String message, String authorName, String authorEmail) throws IOException {\n        var user = authorEmail == null ? authorName : authorName + \" <\" + authorEmail + \">\";\n        try (var p = capture(\"hg\", \"commit\", \"--amend\", \"--message=\" + message, \"--user=\" + user)) {\n            await(p);\n        }\n        return resolve(\"tip\").orElseThrow(() -> new IOException(\"Could not resolve 'tip'\"));\n    }\n\n    @Override\n    public Hash amend(String message, String authorName, String authorEmail, String committerName, String committerEmail) throws IOException {\n        if (!Objects.equals(authorName, committerName) ||\n            !Objects.equals(authorEmail, committerEmail)) {\n            throw new IllegalArgumentException(\"hg does not support different author and committer data\");\n        }\n\n        return amend(message, authorName, authorEmail);\n    }\n\n    @Override\n    public Tag tag(Hash hash, String name, String message, String authorName, String authorEmail, ZonedDateTime date, boolean force) throws IOException {\n        var user = authorEmail != null ?\n            authorName + \" <\" + authorEmail + \">\" :\n            authorName;\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"hg\", \"tag\",\n                           \"--message\", message,\n                           \"--user\", user,\n                           \"--rev\", hash.hex()));\n        if (date != null) {\n            cmd.add(\"--date\");\n            cmd.add(date.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));\n        }\n        if (force) {\n            cmd.add(\"--force\");\n        }\n        cmd.add(name);\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n\n        return new Tag(name);\n    }\n\n    @Override\n    public Branch branch(Hash hash, String name) throws IOException {\n        // Model a lightweight branch with a bookmark. Not ideal but the\n        // closest to git branches.\n        try (var p = capture(\"hg\", \"bookmark\", \"--rev\", hash.hex(), name)) {\n            await(p);\n        }\n\n        return new Branch(name);\n    }\n\n    @Override\n    public void prune(Branch branch, String remote) throws IOException {\n        try (var p = capture(\"hg\", \"bookmark\", \"--delete\", branch.name())) {\n            await(p);\n        }\n        try (var p = capture(\"hg\", \"push\", \"--bookmark\", branch.name(), remote)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public Hash mergeBase(Hash first, Hash second) throws IOException {\n        var revset = \"ancestor(\" + first.hex() + \", \" + second.hex() + \")\";\n        try (var p = capture(\"hg\", \"log\", \"--rev=\" + revset, \"--template={node}\\n\")) {\n            var res = await(p);\n            if (res.stdout().size() != 1) {\n                throw new IOException(\"Unexpected output\\n\" + res);\n            }\n            return new Hash(res.stdout().get(0));\n        }\n    }\n\n    @Override\n    public boolean isAncestor(Hash ancestor, Hash descendant) throws IOException {\n        return mergeBase(ancestor, descendant).equals(ancestor);\n    }\n\n    /**\n     * Check if either a is an ancestor of b, or b is an ancestor of a.\n     * @return true if a and b are related\n     */\n    private boolean isRelated(Hash a, Hash b) throws IOException {\n        var base = mergeBase(a, b);\n        return base.equals(a) || base.equals(b);\n    }\n\n    @Override\n    public void rebase(Hash hash, String committerName, String committerEmail) throws IOException {\n        var current = currentBranch().orElseThrow(() ->\n                new IOException(\"No current branch to rebase upon\")\n        );\n        try (var p = capture(\"hg\", \"--config\", \"extensions.rebase=\",\n                             \"rebase\", \"--dest\", hash.hex(), \"--base\", current.name())) {\n            await(p);\n        }\n    }\n\n    @Override\n    public Optional<Branch> currentBranch() throws IOException {\n        try (var p = capture(\"hg\", \"branch\")) {\n            var res = await(p);\n            if (res.stdout().size() != 1) {\n                return Optional.empty();\n            }\n            return Optional.of(new Branch(res.stdout().get(0)));\n        }\n    }\n\n    @Override\n    public Optional<Bookmark> currentBookmark() throws IOException {\n        try (var p = capture(\"hg\", \"log\", \"-r\", \".\", \"--template\", \"{activebookmark}\\n\")) {\n            var res = await(p);\n            if (res.stdout().size() == 1) {\n                return Optional.of(new Bookmark(res.stdout().get(0)));\n            }\n            return Optional.empty();\n        }\n    }\n\n    @Override\n    public Branch defaultBranch() throws IOException {\n        return Branch.defaultFor(VCS.HG);\n    }\n\n    @Override\n    public Optional<Tag> defaultTag() throws IOException {\n        return Optional.of(new Tag(\"tip\"));\n    }\n\n    @Override\n    public Optional<byte[]> show(Path path, Hash hash) throws IOException {\n        var output = Files.createTempFile(\"hg-cat-rev-\" + hash.abbreviate(), \".bin\");\n        try (var p = capture(\"hg\", \"cat\", \"--output=\" + output, \"--rev=\" + hash.hex(), path.toString())) {\n            var res = p.await();\n            if (res.status() == 0 && Files.exists(output)) {\n                var bytes = Files.readAllBytes(output);\n                Files.delete(output);\n                return Optional.of(bytes);\n            }\n\n            if (Files.exists(output)) {\n                Files.delete(output);\n            }\n            return Optional.empty();\n        }\n    }\n\n    private List<FileEntry> allFiles(Hash hash, List<Path> paths) throws IOException {\n        var ext = Files.createTempFile(\"ext\", \".py\");\n        copyResource(EXT_PY, ext);\n\n        var include = new HashSet<>(paths);\n\n        try (var p = capture(\"hg\", \"--config\", \"extensions.ls-tree=\" + ext, \"ls-tree\", hash.hex())) {\n            var res = await(p);\n            var entries = new ArrayList<FileEntry>();\n            for (var line : res.stdout()) {\n                var parts = line.split(\"\\t\");\n                var metadata = parts[0].split(\" \");\n                var path = Path.of(parts[1]);\n                if (include.isEmpty() || include.contains(path)) {\n                    var entry = new FileEntry(hash,\n                                              FileType.fromOctal(metadata[0]),\n                                              new Hash(metadata[2]),\n                                              path);\n                    entries.add(entry);\n                }\n            }\n            return entries;\n        }\n    }\n\n    @Override\n    public List<FileEntry> files(Hash hash, List<Path> paths) throws IOException {\n        if (paths.isEmpty()) {\n            return allFiles(hash, paths);\n        }\n\n        var entries = new ArrayList<FileEntry>();\n        var batchSize = 64;\n        var start = 0;\n        while (start < paths.size()) {\n            var end = start + batchSize;\n            if (end > paths.size()) {\n                end = paths.size();\n            }\n            entries.addAll(allFiles(hash, paths.subList(start, end)));\n            start = end;\n        }\n        return entries;\n    }\n\n    @Override\n    public List<StatusEntry> status(Hash from, Hash to) throws IOException {\n        var ext = Files.createTempFile(\"ext\", \".py\");\n        copyResource(EXT_PY, ext);\n\n        try (var p = capture(\"hg\", \"--config\", \"extensions.diff-git-raw=\" + ext.toAbsolutePath().toString(),\n                                               \"diff-git-raw\", from.hex(), to.hex())) {\n            var res = await(p);\n            var entries = new ArrayList<StatusEntry>();\n            for (var line : res.stdout()) {\n                entries.add(StatusEntry.fromRawLine(line));\n            }\n            return entries;\n        }\n    }\n\n    @Override\n    public List<StatusEntry> status() throws IOException {\n        // TODO: can use merge.mergestate.read(repo) to implement diff-git-raw-workspace\n        throw new RuntimeException(\"Not implemented yet\");\n    }\n\n    @Override\n    public void dump(FileEntry entry, Path to) throws IOException {\n        var output = to.toAbsolutePath();\n        try (var p = capture(\"hg\", \"cat\", \"--output=\" + output.toString(),\n                                          \"--rev=\" + entry.commit(),\n                                          entry.path().toString())) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void revert(Hash parent) throws IOException {\n        try (var p = capture(\"hg\", \"revert\", \"--no-backup\", \"--all\", \"--rev\", parent.hex())) {\n            await(p);\n        }\n    }\n\n    @Override\n    public Diff diff(Hash from, int similarity) throws IOException {\n        return diff(from, List.of());\n    }\n\n    @Override\n    public Diff diff(Hash from, List<Path> files, int similarity) throws IOException {\n        return diff(from, null, files);\n    }\n\n    @Override\n    public Diff diff(Hash from, Hash to, int similarity) throws IOException {\n        return diff(from, to, List.of());\n    }\n\n    @Override\n    public Diff diff(Hash from, Hash to, List<Path> files, int similarity) throws IOException {\n        var ext = Files.createTempFile(\"ext\", \".py\");\n        copyResource(EXT_PY, ext);\n\n        var cmd = new ArrayList<>(List.of(\"hg\", \"--config\", \"extensions.diff-git-raw=\" + ext.toAbsolutePath(),\n                                                \"diff-git-raw\", \"--patch\", from.hex()));\n        if (to != null) {\n            cmd.add(to.hex());\n        }\n\n        if (files != null) {\n            var filenames = files.stream().map(Path::toString).collect(Collectors.toList());\n            cmd.add(\"--files=\" + String.join(\",\", filenames));\n        }\n\n        var p = start(cmd);\n        try {\n            var patches = GitRawDiffParser.parse(p.getInputStream());\n            await(p);\n            return new Diff(from, to, patches);\n        } catch (Throwable t) {\n            throw new IOException(t);\n        }\n    }\n\n    @Override\n    public Optional<String> username() throws IOException {\n        var lines = config(\"ui.username\");\n        return lines.size() == 1 ? Optional.of(lines.get(0)) : Optional.empty();\n    }\n\n    @Override\n    public Hash head() throws IOException {\n        return resolve(\".\").orElseThrow(() -> new IOException(\". not available\"));\n    }\n\n    private List<Hash> heads() throws IOException {\n        var heads = new ArrayList<Hash>();\n        try (var p = capture(\"hg\", \"heads\", \"--template={node}\\n\")) {\n            var res = p.await();\n            if (res.status() == 0) {\n                for (var hash : res.stdout()) {\n                    heads.add(new Hash(hash));\n                }\n            }\n        }\n        return heads;\n    }\n\n    @Override\n    public List<String> config(String key) throws IOException {\n        // Do not use HgRepository.capture() here, want to run *with*\n        // hg configuration.\n        try (var p = Process.capture(\"hg\", \"showconfig\", key)\n                            .workdir(dir)\n                            .execute()) {\n            var res = p.await();\n            if (res.status() == 1) {\n                return List.of();\n            }\n            return res.stdout();\n        }\n    }\n\n    public static Optional<Repository> get(Path p) throws IOException {\n        if (!Files.exists(p)) {\n            return Optional.empty();\n        }\n\n        var r = new HgRepository(p);\n        return r.exists() ? Optional.of(new HgRepository(r.root())) : Optional.empty();\n    }\n\n    @Override\n    public Repository copyTo(Path destination) throws IOException {\n        var from = root().toAbsolutePath().toString();\n        var to = destination.toAbsolutePath().toString();\n        try (var p = capture(\"hg\", \"clone\", from, to)) {\n            await(p);\n        }\n\n        return new HgRepository(destination.toAbsolutePath());\n    }\n\n    @Override\n    public void merge(Hash h, FastForward ff) throws IOException {\n        merge(h, null, ff);\n    }\n\n    @Override\n    public void merge(Branch b, FastForward ff) throws IOException {\n        var hash = resolve(b).orElseThrow(() ->\n            new IOException(\"Can't lookup branch \" + b.name())\n        );\n        merge(hash, null, ff);\n    }\n\n    @Override\n    public void merge(Hash other, String strategy, FastForward ff) throws IOException {\n        var head = head();\n        List<String> cmd = null;\n\n        var update = List.of(\"hg\", \"update\", \"--rev\", other.hex());\n        var debugsetparents = List.of(\"hg\", \"debugsetparents\", head.hex(), other.hex());\n        var merge = new ArrayList<>(List.of(\"hg\", \"merge\", \"--rev=\" + other.hex()));\n        if (strategy != null) {\n            merge.add(\"--tool=\" + strategy);\n        }\n\n        if (ff == FastForward.ONLY) {\n            cmd = update;\n        } else if (ff == FastForward.DISABLE) {\n            if (isRelated(head, other)) {\n                cmd = debugsetparents;\n            } else {\n                cmd = merge;\n            }\n        } else if (ff == FastForward.AUTO) {\n            if (isAncestor(head, other)) {\n                cmd = update;\n            } else if (isAncestor(other, head)) {\n                return;\n            } else {\n                cmd = merge;\n            }\n        } else {\n            throw new IllegalArgumentException(\"Unexpected fast forward value: \" + ff);\n        }\n\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void abortMerge() throws IOException {\n        try (var p = capture(\"hg\", \"merge\", \"--abort\")) {\n            await(p);\n        }\n\n        try (var p = capture(\"hg\", \"status\", \"--unknown\", \"--no-status\")) {\n            var res = await(p);\n            for (var path : res.stdout()) {\n                if (path.toString().endsWith(\".orig\")) {\n                    Files.delete(root().resolve(path));\n                }\n            }\n        }\n    }\n\n    @Override\n    public void addRemote(String name, String path) throws IOException {\n        setPaths(name, path, path);\n    }\n\n    @Override\n    public void setPaths(String remote, String pullPath, String pushPath) throws IOException {\n        var hgrc = Path.of(root().toString(), \".hg\", \"hgrc\");\n        if (!Files.exists(hgrc)) {\n            Files.createFile(hgrc);\n        }\n\n        var lines = Files.readAllLines(hgrc);\n        var newLines = new ArrayList<String>();\n\n        var isInPathsSection = false;\n        var hasPathsSection = false;\n        for (var line : lines) {\n            var isSectionHeader = line.startsWith(\"[\") && line.endsWith(\"]\");\n            if (isSectionHeader && !isInPathsSection) {\n                isInPathsSection = line.equals(\"[paths]\");\n                if (isInPathsSection) {\n                    newLines.add(line);\n                    newLines.add(remote + \" = \" + (pullPath == null ? \"\" : pullPath));\n                    newLines.add(remote + \"-push = \" + (pushPath == null ? \"\" : pushPath));\n                    hasPathsSection = true;\n                    continue;\n                }\n            }\n\n            if (isInPathsSection && line.startsWith(remote)) {\n                if (line.startsWith(remote + \"-push\")) {\n                    // skip\n                } else if (line.startsWith(remote + \":pushurl\")) {\n                    // skip\n                } else if (line.startsWith(remote + \" \") || line.startsWith(remote + \"=\")) {\n                    // skip\n                } else {\n                    newLines.add(line);\n                }\n            } else {\n                newLines.add(line);\n            }\n        }\n\n        Files.write(hgrc, newLines, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);\n        if (!hasPathsSection) {\n            var section = List.of(\"[paths]\",\n                                  remote + \" = \" + (pullPath == null ? \"\" : pullPath),\n                                  remote + \"-push = \" + (pushPath == null ? \"\" : pushPath));\n            Files.write(hgrc, section, StandardOpenOption.WRITE, StandardOpenOption.APPEND);\n        }\n    }\n\n    @Override\n    public String pullPath(String remote) throws IOException {\n        var lines = config(\"paths.\" + remote);\n        if (lines.size() != 1) {\n            throw new IOException(\"Pull path not found for remote: \" + remote);\n        }\n        return lines.get(0);\n    }\n\n    @Override\n    public String pushPath(String remote) throws IOException {\n        var lines = config(\"paths.\" + remote + \"-push\");\n        if (lines.size() != 1) {\n            lines = config(\"paths.\" + remote + \"@push\");\n        }\n        if (lines.size() != 1) {\n            return pullPath(remote);\n        }\n        return lines.get(0);\n    }\n\n    @Override\n    public boolean isValidRevisionRange(String expression) throws IOException {\n        try (var p = capture(\"hg\", \"log\", \"--template\", \" \", \"--rev\", expression)) {\n            return p.await().status() == 0;\n        }\n    }\n\n    private void setPermissions(Patch.Info target) throws IOException {\n        if (target.path().isPresent() && target.type().isPresent()) {\n            var perms = target.type().get().permissions();\n            if (perms.isPresent()) {\n                Files.setPosixFilePermissions(target.path().get(), perms.get());\n            }\n        }\n    }\n\n    @Override\n    public void apply(Diff diff, boolean force) throws IOException {\n        var patchFile = Files.createTempFile(\"import\", \".patch\");\n        diff.toFile(patchFile);\n        apply(patchFile, force);\n        Files.delete(patchFile);\n    }\n\n    @Override\n    public void apply(Path patchFile, boolean force) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"hg\", \"import\", \"--no-commit\"));\n        if (force) {\n            cmd.add(\"--force\");\n        }\n        cmd.add(patchFile.toAbsolutePath().toString());\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void copy(Path from, Path to) throws IOException {\n        try (var p = capture(\"hg\", \"copy\", from.toString(), to.toString())) {\n            await(p);\n        }\n    }\n\n    @Override\n    public void move(Path from, Path to) throws IOException {\n        try (var p = capture(\"hg\", \"move\", from.toString(), to.toString())) {\n            await(p);\n        }\n    }\n\n    @FunctionalInterface\n    private static interface Operation {\n        void execute(List<Path> args) throws IOException;\n    }\n\n    private void batch(Operation op, List<Path> args) throws IOException {\n        var batchSize = 64;\n        var start = 0;\n        while (start < args.size()) {\n            var end = start + batchSize;\n            if (end > args.size()) {\n                end = args.size();\n            }\n            op.execute(args.subList(start, end));\n            start = end;\n        }\n    }\n\n    private void addAll(List<Path> paths) throws IOException {\n        var cmd = new ArrayList<>(List.of(\"hg\", \"add\"));\n        for (var path : paths) {\n            cmd.add(path.toString());\n        }\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    private void removeAll(List<Path> paths) throws IOException {\n        var cmd = new ArrayList<>(List.of(\"hg\", \"rm\"));\n        for (var path : paths) {\n            cmd.add(path.toString());\n        }\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n\n    @Override\n    public void remove(List<Path> paths) throws IOException {\n        batch(this::removeAll, paths);\n    }\n\n    @Override\n    public void add(List<Path> paths) throws IOException {\n        batch(this::addAll, paths);\n    }\n\n    @Override\n    public void addremove() throws IOException {\n        try (var p = capture(\"hg\", \"addremove\")) {\n            await(p);\n        }\n    }\n\n    @Override\n    public Optional<String> upstreamFor(Branch b) throws IOException {\n        // Mercurial doesn't have the concept of remotes like git,\n        // a local branch must have the same name (if present) on the remote\n        return Optional.of(b.name());\n    }\n\n    public static Repository clone(URI from, Path to, boolean isBare, Path seed) throws IOException {\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"hg\", \"clone\"));\n        if (isBare) {\n            cmd.add(\"--noupdate\");\n        }\n        cmd.addAll(List.of(from.toString(), to.toString()));\n\n        try (var p = capture(Path.of(\"\").toAbsolutePath(), cmd)) {\n            await(p);\n        }\n        return new HgRepository(to);\n    }\n\n    @Override\n    public void pull(boolean includeTags) throws IOException {\n        pull(null, null, includeTags);\n    }\n\n    @Override\n    public void pull(String remote, boolean includeTags) throws IOException {\n        pull(remote, null, includeTags);\n    }\n\n    @Override\n    public void pull(String remote, String refspec, boolean includeTags) throws IOException {\n        // ignore includeTags, hg always pulls tags\n        var cmd = new ArrayList<String>();\n        cmd.addAll(List.of(\"hg\", \"pull\", \"--update\"));\n        if (refspec != null) {\n            cmd.add(\"--branch\");\n            cmd.add(refspec);\n        }\n        if (remote != null) {\n            cmd.add(remote);\n        }\n        try (var p = capture(cmd)) {\n            await(p);\n        }\n    }\n\n    @Override\n    public boolean contains(Branch b, Hash h) throws IOException {\n        try (var p = capture(\"hg\", \"log\", \"--template\", \"{branch}\", \"-r\", h.hex())) {\n            var res = await(p);\n            if (res.stdout().size() != 1) {\n                throw new IOException(\"Unexpected output: \" + String.join(\"\\n\", res.stdout()));\n            }\n            var line = res.stdout().get(0);\n            return line.equals(b.name());\n        }\n    }\n\n    @Override\n    public List<Reference> remoteBranches(String remote) throws IOException {\n        var refs = new ArrayList<Reference>();\n\n        var ext = Files.createTempFile(\"ext\", \".py\");\n        copyResource(EXT_PY, ext);\n\n        try (var p = capture(\"hg\", \"--config\", \"extensions.ls-remote=\" + ext, \"ls-remote\", remote)) {\n            var res = await(p);\n            for (var line : res.stdout()) {\n                var parts = line.split(\"\\t\");\n                refs.add(new Reference(parts[1], new Hash(parts[0])));\n            }\n        }\n        return refs;\n    }\n\n    @Override\n    public List<String> remotes() throws IOException {\n        var remotes = new ArrayList<String>();\n        try (var p = capture(\"hg\", \"paths\")) {\n            for (var line : await(p).stdout()) {\n                var parts = line.split(\" = \");\n                var name = parts[0];\n                if (name.endsWith(\"-push\") || name.endsWith(\":push\")) {\n                    continue;\n                } else {\n                    remotes.add(name);\n                }\n            }\n        }\n        return remotes;\n    }\n\n    @Override\n    public void addSubmodule(String pullPath, Path path) throws IOException {\n        var uri = Files.exists(Path.of(pullPath)) ? Path.of(pullPath).toUri().toString() : pullPath;\n        HgRepository.clone(URI.create(uri), root().resolve(path).toAbsolutePath(), false, null);\n        var hgSub = root().resolve(\".hgsub\");\n        Files.writeString(hgSub, path.toString() + \" = \" + pullPath + \"\\n\",\n                          StandardOpenOption.WRITE, StandardOpenOption.APPEND, StandardOpenOption.CREATE);\n        add(List.of(hgSub));\n    }\n\n    @Override\n    public void updateSubmodule(Path path) throws IOException {\n        checkout(\".\", false);\n    }\n\n    @Override\n    public List<Submodule> submodules() throws IOException {\n        var hgSub = root().resolve(\".hgsub\");\n        var hgSubState = root().resolve(\".hgsubstate\");\n        if (!(Files.exists(hgSub) && Files.exists(hgSubState))) {\n            return List.of();\n        }\n\n        var urls = new HashMap<String, String>();\n        for (var line : Files.readAllLines(hgSub)) {\n            var parts = line.split(\"=\");\n            var path = parts[0].trim();\n            var url = parts[1].trim();\n            urls.put(path, url);\n        }\n\n        var hashes = new HashMap<String, String>();\n        for (var line : Files.readAllLines(hgSubState)) {\n            var parts = line.split(\" \");\n            var hash = parts[0];\n            var path = parts[1];\n            hashes.put(path, hash);\n        }\n\n        var modules = new ArrayList<Submodule>();\n        for (var path : urls.keySet()) {\n            var url = urls.get(path);\n            var hash = hashes.get(path);\n            modules.add(new Submodule(new Hash(hash), Path.of(path), url));\n        }\n\n        return modules;\n    }\n\n    @Override\n    public Tree tree(Hash h) throws IOException {\n        throw new RuntimeException(\"not implemented yet\");\n    }\n\n    @Override\n    public Optional<Tag.Annotated> annotate(Tag tag) throws IOException {\n        var hgtags = root().resolve(\".hgtags\");\n        if (!Files.exists(hgtags)) {\n            return Optional.empty();\n        }\n        try (var p = capture(\"hg\", \"annotate\", hgtags.toString())) {\n            var reversed = new ArrayList<>(await(p).stdout());\n            Collections.reverse(reversed);\n            for (var line : reversed) {\n                var parts = line.split(\" \");\n                var tagName = parts[2];\n                if (tagName.equals(tag.name())) {\n                    var target = new Hash(parts[1]);\n                    var rev = parts[0].substring(0, parts[0].length() - 1).trim(); // skip last ':' and ev. whitespace\n                    var hash = resolve(rev).orElseThrow(IOException::new);\n                    var commit = lookup(hash).orElseThrow(IOException::new);\n                    var message = String.join(\"\\n\", commit.message());\n                    return Optional.of(new Tag.Annotated(tagName, target, commit.author(), commit.authored(), message));\n                }\n            }\n        }\n        return Optional.empty();\n    }\n\n    @Override\n    public void config(String section, String key, String value, boolean global) throws IOException {\n        var hgrc = global ?\n            Path.of(System.getProperty(\"user.home\"), \".hgrc\") :\n            root().resolve(\".hg\").resolve(\"hgrc\");\n\n        var lines = List.of(\n            \"[\" + section + \"]\",\n            key + \" = \" + value\n        );\n        if (!Files.exists(hgrc)) {\n            Files.createFile(hgrc);\n        }\n        Files.write(hgrc, lines, StandardOpenOption.WRITE, StandardOpenOption.APPEND);\n    }\n\n    @Override\n    public String range(Hash h) {\n        return h.hex();\n    }\n\n    @Override\n    public String rangeInclusive(Hash from, Hash to) {\n        return from.hex() + \":\" + to.hex();\n    }\n\n    @Override\n    public String rangeExclusive(Hash from, Hash to) {\n        return from.hex() + \":\" + to.hex() + \"-\" + from.hex();\n    }\n\n    @Override\n    public boolean cherryPick(Hash hash) throws IOException {\n        try (var p = capture(\"hg\", \"graft\", \"--no-commit\", \"--force\", hash.hex())) {\n            return p.await().status() == 0;\n        }\n    }\n\n    @Override\n    public int commitCount() throws IOException {\n        try (var p = capture(\"hg\", \"id\", \"--num\", \"--rev\", \"tip\")) {\n            return Integer.parseInt(await(p).stdout().get(0)) + 1;\n        }\n    }\n\n    @Override\n    public int commitCount(List<Branch> branches) throws IOException {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public Hash initialHash() {\n        return NULL_REVISION;\n    }\n\n    @Override\n    public Optional<List<String>> stagedFileContents(Path p) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public Commit staged() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public Commit workingTree() {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public boolean isEmptyCommit(Hash hash) {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void addNote(Hash hash,\n                        List<String> lines,\n                        String authorName,\n                        String authorEmail,\n                        String committerName,\n                        String committerEmail) throws IOException {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public List<String> notes(Hash hash) throws IOException {\n        throw new UnsupportedOperationException();\n    }\n\n    @Override\n    public void pushNotes(URI uri) throws IOException {\n        throw new UnsupportedOperationException();\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/CommitMessage.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk;\n\nimport org.openjdk.skara.vcs.Author;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.util.List;\nimport java.util.Optional;\n\npublic class CommitMessage {\n    private final String title;\n    private final List<Issue> issues;\n    private final List<String> reviewers;\n    private final List<Author> contributors;\n    private final List<String> summaries;\n    private final Hash original;\n    private final List<CustomTrailer> customTrailers;\n    private final List<String> additional;\n\n    public record CustomTrailer(String key, String value) {}\n\n    public CommitMessage(String title,\n                         List<Issue> issues,\n                         List<String> reviewers,\n                         List<Author> contributors,\n                         List<String> summaries,\n                         Hash original,\n                         List<CustomTrailer> customTrailers,\n                         List<String> additional) {\n        this.title = title;\n        this.issues = issues;\n        this.reviewers = reviewers;\n        this.contributors = contributors;\n        this.summaries = summaries;\n        this.original = original;\n        this.customTrailers = customTrailers;\n        this.additional = additional;\n    }\n\n    public String title() {\n        return title;\n    }\n\n    public List<Issue> issues() {\n        return issues;\n    }\n\n    public List<String> reviewers() {\n        return reviewers;\n    }\n\n    public List<Author> contributors() {\n        return contributors;\n    }\n\n    public void addContributor(Author contributor) {\n        contributors.add(contributor);\n    }\n\n    public List<String> summaries() {\n        return summaries;\n    }\n\n    public Optional<Hash> original() {\n        return Optional.ofNullable(original);\n    }\n\n    public List<CustomTrailer> customTrailers() {\n        return customTrailers;\n    }\n\n    public List<String> additional() {\n        return additional;\n    }\n\n    public static CommitMessageBuilder title(String title) {\n        if (title == null || title.isEmpty()) {\n            throw new IllegalArgumentException(\"Must provide a non-empty title\");\n        }\n\n        var builder = new CommitMessageBuilder();\n        builder.title(title);\n        return builder;\n    }\n\n    public static CommitMessageBuilder title(Issue... issues) {\n        var builder = new CommitMessageBuilder();\n        builder.issues(issues);\n        return builder;\n    }\n\n    public static CommitMessageBuilder title(List<Issue> issues) {\n        var builder = new CommitMessageBuilder();\n        builder.issues(issues);\n        return builder;\n    }\n\n    public List<String> format(CommitMessageFormatter formatter) {\n        return formatter.format(this);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/CommitMessageBuilder.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk;\n\nimport org.openjdk.skara.vcs.Author;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.util.*;\nimport java.util.stream.Collectors;\n\npublic class CommitMessageBuilder {\n    private String title = null;\n    private Hash original = null;\n    private List<Issue> issues = new ArrayList<>();\n    private List<String> summaries = new ArrayList<>();\n    private List<String> reviewers = new ArrayList<>();\n    private List<Author> contributors = new ArrayList<>();\n    private List<CommitMessage.CustomTrailer> customTrailers = new ArrayList<>();\n\n    CommitMessageBuilder() {\n    }\n\n    CommitMessageBuilder title(String title) {\n        this.title = title;\n        return this;\n    }\n\n    public CommitMessageBuilder issues(Issue... issues) {\n        for (var issue : issues) {\n            this.issues.add(issue);\n        }\n        return this;\n    }\n\n    public CommitMessageBuilder issues(List<Issue> issues) {\n        this.issues.addAll(issues);\n        return this;\n    }\n\n    public CommitMessageBuilder issue(Issue issue) {\n        issues.add(issue);\n        return this;\n    }\n\n    public CommitMessageBuilder summaries(List<String> summaries) {\n        this.summaries.addAll(summaries);\n        return this;\n    }\n\n    public CommitMessageBuilder summaries(String... summaries) {\n        for (var summary : summaries) {\n            this.summaries.add(summary);\n        }\n        return this;\n    }\n\n    public CommitMessageBuilder summary(String summary) {\n        summaries.add(summary);\n        return this;\n    }\n\n    public CommitMessageBuilder reviewers(List<String> reviewers) {\n        this.reviewers.addAll(reviewers);\n        return this;\n    }\n\n    public CommitMessageBuilder reviewers(String... reviewers) {\n        for (var reviewer : reviewers) {\n            this.reviewers.add(reviewer);\n        }\n        return this;\n    }\n\n    public CommitMessageBuilder reviewer(String reviewer) {\n        reviewers.add(reviewer);\n        return this;\n    }\n\n    public CommitMessageBuilder contributors(List<Author> contributors) {\n        this.contributors.addAll(contributors);\n        return this;\n    }\n\n    public CommitMessageBuilder contributors(Author... contributors) {\n        for (var contributor : contributors) {\n            this.contributors.add(contributor);\n        }\n        return this;\n    }\n\n    public CommitMessageBuilder original(Hash original) {\n        this.original = original;\n        return this;\n    }\n\n    public CommitMessageBuilder contributor(Author contributor) {\n        contributors.add(contributor);\n        return this;\n    }\n\n    public CommitMessageBuilder customTrailers(List<CommitMessage.CustomTrailer> customTrailers) {\n        this.customTrailers.addAll(customTrailers);\n        return this;\n    }\n\n    public CommitMessageBuilder customTrailers(CommitMessage.CustomTrailer... customTrailers) {\n        Collections.addAll(this.customTrailers, customTrailers);\n        return this;\n    }\n\n    public CommitMessage create() {\n        return new CommitMessage(title, issues, reviewers, contributors, summaries, original, customTrailers, List.of());\n    }\n\n    public List<String> format(CommitMessageFormatter formatter) {\n        return create().format(formatter);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/CommitMessageFormatter.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\nimport org.openjdk.skara.vcs.Author;\n\n@FunctionalInterface\npublic interface CommitMessageFormatter {\n    List<String> format(CommitMessage message);\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/CommitMessageFormatters.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk;\n\nimport org.openjdk.skara.vcs.Author;\n\nimport java.util.*;\nimport java.util.stream.Collectors;\n\npublic class CommitMessageFormatters {\n    public static class V0 implements CommitMessageFormatter {\n        public List<String> format(CommitMessage message) {\n            if (message.issues().isEmpty()) {\n                throw new IllegalArgumentException(\"Must supply at least one issue\");\n            }\n\n            var lines = new ArrayList<String>();\n\n            for (var issue : message.issues()) {\n                lines.add(issue.toShortString());\n            }\n            for (var summary : message.summaries()) {\n                lines.add(\"Summary: \" + summary);\n            }\n            if (message.reviewers().size() > 0) {\n                lines.add(\"Reviewed-by: \" + String.join(\", \", message.reviewers()));\n            }\n            if (message.contributors().size() > 0) {\n                lines.add(\"Contributed-by: \" + message.contributors()\n                                                      .stream()\n                                                      .map(Author::toString)\n                                                      .collect(Collectors.joining(\", \")));\n            }\n\n            return lines;\n        }\n    }\n\n    public static class V1 implements CommitMessageFormatter {\n        public List<String> format(CommitMessage message) {\n            if (message.title() != null && !message.issues().isEmpty()) {\n                throw new IllegalArgumentException(\"Can't format both title and issues\");\n            }\n\n            var lines = new ArrayList<String>();\n\n            if (message.title() != null) {\n                lines.add(message.title());\n            } else {\n                for (var issue : message.issues()) {\n                    lines.add(issue.toShortString());\n                }\n            }\n\n            if (message.summaries().size() > 0) {\n                lines.add(\"\");\n                for (var summary : message.summaries()) {\n                    lines.add(summary);\n                }\n            }\n\n            if (((message.reviewers().size() + message.contributors().size()) > 0) ||\n                 message.original().isPresent() || !message.customTrailers().isEmpty()) {\n                lines.add(\"\");\n                if (message.contributors().size() > 0) {\n                    for (var contributor : message.contributors()) {\n                        lines.add(\"Co-authored-by: \" + contributor.toString());\n                    }\n                }\n                if (message.reviewers().size() > 0) {\n                    lines.add(\"Reviewed-by: \" + String.join(\", \", message.reviewers()));\n                }\n                if (message.original().isPresent()) {\n                    lines.add(\"Backport-of: \" + message.original().get().hex());\n                }\n                for (CommitMessage.CustomTrailer customTrailer : message.customTrailers()) {\n                    lines.add(customTrailer.key() + \": \" + customTrailer.value());\n                }\n            }\n\n            return lines;\n        }\n    }\n\n    public static CommitMessageFormatter v0 = new V0();\n    public static CommitMessageFormatter v1 = new V1();\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/CommitMessageParser.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk;\n\nimport org.openjdk.skara.vcs.Commit;\nimport org.openjdk.skara.vcs.CommitMetadata;\n\nimport java.util.List;\n\n@FunctionalInterface\npublic interface CommitMessageParser {\n    CommitMessage parse(List<String> lines);\n    default CommitMessage parse(Commit c) {\n        return parse(c.message());\n    }\n    default CommitMessage parse(CommitMetadata metadata) {\n        return parse(metadata.message());\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/CommitMessageParsers.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk;\n\nimport org.openjdk.skara.vcs.Author;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.util.*;\nimport java.util.regex.*;\nimport java.util.stream.Collectors;\n\nimport static org.openjdk.skara.vcs.openjdk.CommitMessageSyntax.*;\n\npublic class CommitMessageParsers {\n\n    private static final Pattern WHITESPACE_OR_EMPTY = Pattern.compile(\"\\\\s*\");\n\n    private static Matcher matcher(Pattern p, List<String> lines, int index) {\n        if (index >= lines.size()) {\n            return null;\n        }\n\n        var m = p.matcher(lines.get(index));\n        return m.matches() ? m : null;\n    }\n\n    public static class V0 implements CommitMessageParser {\n        public CommitMessage parse(List<String> lines) {\n            var i = 0;\n            Matcher m = null;\n\n            var issues = new ArrayList<Issue>();\n            while ((m = matcher(ISSUE_PATTERN, lines, i)) != null) {\n                var id = m.group(1);\n                var desc = m.group(2);\n                issues.add(new Issue(id, desc));\n                i++;\n            }\n\n            var summaries = new ArrayList<String>();\n            while ((m = matcher(SUMMARY_PATTERN, lines, i)) != null) {\n                summaries.add(m.group(1));\n                i++;\n            }\n\n            var reviewers = new ArrayList<String>();\n            while ((m = matcher(REVIEWED_BY_PATTERN, lines, i)) != null) {\n                for (var name : m.group(1).split(\", \")) {\n                    reviewers.add(name);\n                }\n                i++;\n            }\n\n            var contributors = new ArrayList<Author>();\n            while ((m = matcher(CONTRIBUTED_BY_PATTERN, lines, i)) != null) {\n                for (var attribution : m.group(1).split(\", \")) {\n                    if (attribution.contains(\" \")) {\n                        // must be 'Real Name <email>' variant\n                        contributors.add(Author.fromString(attribution));\n                    } else {\n                        // must be the email only variant\n                        contributors.add(new Author(\"\", attribution));\n                    }\n                }\n                i++;\n            }\n\n            var additional = lines.subList(i, lines.size());\n            return new CommitMessage(null, issues, reviewers, contributors, summaries, null, List.of(), additional);\n        }\n    }\n\n    public static class V1 implements CommitMessageParser {\n        public CommitMessage parse(List<String> lines) {\n            var i = 0;\n            Matcher m = null;\n\n            var issues = new ArrayList<Issue>();\n            while ((m = matcher(ISSUE_PATTERN, lines, i)) != null) {\n                var id = m.group(1);\n                var desc = m.group(2);\n                issues.add(new Issue(id, desc));\n                i++;\n            }\n\n            String title = null;\n            if (issues.size() == 0 && i < lines.size()) {\n                // the first line wasn't an issue, treat is a generic title\n                title = lines.get(0);\n                i++;\n            } else {\n                title = issues.stream().map(Issue::toShortString).collect(Collectors.joining(\"\\n\"));\n            }\n\n            var firstDelimiter = true;\n            var summaries = new ArrayList<String>();\n            var coAuthors = new ArrayList<Author>();\n            var reviewers = new ArrayList<String>();\n            Hash original = null;\n            var customTrailers = new ArrayList<CommitMessage.CustomTrailer>();\n\n            // Parse summary and trailers, see https://git-scm.com/docs/git-interpret-trailers\n            // for reference. Trailers only appear in the last block of text, after the last\n            // empty, or whitespace only line.\n\n            // Find start of trailer block\n            int trailerStart = lines.size() - 1;\n            for (int j = trailerStart; j > 0; j--) {\n                if (WHITESPACE_OR_EMPTY.matcher(lines.get(j)).matches()) {\n                    trailerStart = j;\n                    break;\n                }\n                if (!GENERIC_TRAILER_PATTERN.matcher(lines.get(j)).matches()) {\n                    break;\n                }\n            }\n\n            // Read summaries up until the trailer start\n            while (i < trailerStart) {\n                i++;\n                if (!firstDelimiter) {\n                    summaries.add(\"\");\n                } else {\n                    firstDelimiter = false;\n                }\n                while (i < lines.size() && !WHITESPACE_OR_EMPTY.matcher(lines.get(i)).matches()) {\n                    summaries.add(lines.get(i));\n                    i++;\n                }\n            }\n\n            // Only try to process trailers if there is a trailer section. If we only\n            // found a single empty/whitespace line, that is not a trailer section and\n            // should be left for \"additional\" below.\n            if (i < lines.size() - 1) {\n                for (; i < lines.size(); i++) {\n                    if ((m = matcher(CO_AUTHOR_PATTERN, lines, i)) != null) {\n                        for (var author : m.group(1).split(\", \")) {\n                            coAuthors.add(Author.fromString(author));\n                        }\n                    } else if ((m = matcher(REVIEWED_BY_PATTERN, lines, i)) != null) {\n                        Collections.addAll(reviewers, m.group(1).split(\", \"));\n                    } else if ((m = matcher(BACKPORT_OF_PATTERN, lines, i)) != null) {\n                        original = new Hash(m.group(1));\n                    } else if ((m = matcher(GENERIC_TRAILER_PATTERN, lines, i)) != null) {\n                        customTrailers.add(new CommitMessage.CustomTrailer(m.group(1), m.group(2)));\n                    }\n                }\n            }\n\n            var additional = lines.subList(i, lines.size());\n            return new CommitMessage(title, issues, reviewers, coAuthors, summaries, original, customTrailers, additional);\n        }\n    }\n\n    public static CommitMessageParser v0 = new V0();\n    public static CommitMessageParser v1 = new V1();\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/CommitMessageSyntax.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk;\n\nimport java.util.regex.Pattern;\n\npublic class CommitMessageSyntax {\n        private static final String OPENJDK_USERNAME_REGEX = \"[-.a-z0-9]+\";\n        public static final String EMAIL_ADDR_REGEX = \"[A-Za-z0-9._%+-]+@[a-z0-9.-]+\\\\.[a-z]+\";\n        public static final String REAL_NAME_REGEX = \"[^<>@]+\";\n        private static final String REAL_NAME_AND_EMAIL_ATTR_REGEX = REAL_NAME_REGEX + \" +<\" + EMAIL_ADDR_REGEX + \">\";\n        private static final String ATTR_REGEX = \"(?:(?:\" + EMAIL_ADDR_REGEX + \")|(?:\" + REAL_NAME_AND_EMAIL_ATTR_REGEX + \"))\";\n\n        public static final Pattern ISSUE_PATTERN = Pattern.compile(\"((?:[A-Z][A-Z0-9]+-)?[0-9]+): (\\\\S.*)$\");\n        public static final Pattern SUMMARY_PATTERN = Pattern.compile(\"Summary: (\\\\S.*)\");\n        public static final Pattern REVIEWED_BY_PATTERN = Pattern.compile(\"Reviewed-by: ((?:\" + OPENJDK_USERNAME_REGEX + \")(?:, \" + OPENJDK_USERNAME_REGEX + \")*)$\");\n        public static final Pattern CONTRIBUTED_BY_PATTERN = Pattern.compile(\"Contributed-by: (\" + ATTR_REGEX + \"(?:, \" + ATTR_REGEX + \")*)$\");\n        public static final Pattern CO_AUTHOR_PATTERN = Pattern.compile(\"Co-authored-by: ((?:\" + REAL_NAME_AND_EMAIL_ATTR_REGEX + \")(?:, \" + REAL_NAME_AND_EMAIL_ATTR_REGEX + \")*)$\");\n        public static final Pattern BACKPORT_OF_PATTERN = Pattern.compile(\"^Backport-of: ([0-9a-z]{40})$\");\n        public static final Pattern GENERIC_TRAILER_PATTERN = Pattern.compile(\"([\\\\p{Alnum}-]+): (.*)\");\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/Issue.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk;\n\nimport java.util.*;\nimport java.util.regex.Pattern;\n\npublic class Issue {\n    private final String project;\n    private final String shortId;\n    private final String description;\n\n    private final static Pattern relaxedIssueParsePattern = Pattern.compile(\"^((?:[A-Z][A-Z0-9]+-)?[0-9]+)[^\\\\p{Alnum}]*\\\\s(\\\\S.*)$\");\n\n    public Issue(String id, String description) {\n        if (id.contains(\"-\")) {\n            var idSplit = id.split(\"-\", 2);\n            project = idSplit[0];\n            this.shortId = idSplit[1];\n        } else {\n            project = null;\n            this.shortId = id;\n        }\n\n        this.description = description;\n    }\n\n    public Optional<String> project() {\n        return Optional.ofNullable(project);\n    }\n\n    public String id() {\n        return (project != null ? project + \"-\" : \"\") + shortId;\n    }\n\n    public String shortId() {\n        return shortId;\n    }\n\n    public String description() {\n        return description;\n    }\n\n    public static Optional<Issue> fromString(String s) {\n        var m = CommitMessageSyntax.ISSUE_PATTERN.matcher(s);\n        if (m.matches()) {\n            var id = m.group(1);\n            var desc = m.group(2);\n            return Optional.of(new Issue(id, desc));\n        }\n        return Optional.empty();\n    }\n\n    public static Optional<Issue> fromStringRelaxed(String s) {\n        var relaxedIssueParseMatcher = relaxedIssueParsePattern.matcher(s.trim());\n        if (relaxedIssueParseMatcher.matches()) {\n            return Optional.of(new Issue(relaxedIssueParseMatcher.group(1), relaxedIssueParseMatcher.group(2)));\n        }\n\n        return Optional.empty();\n    }\n\n    @Override\n    public String toString() {\n        return id() + \": \" + description;\n    }\n\n    public String toShortString() {\n        return shortId + \": \" + description;\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) return true;\n        if (o == null || getClass() != o.getClass()) return false;\n        Issue issue = (Issue) o;\n        return Objects.equals(project, issue.project) &&\n                shortId.equals(issue.shortId) &&\n                description.equals(issue.description);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(project, shortId, description);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/OpenJDKTag.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk;\n\nimport org.openjdk.skara.vcs.Tag;\n\nimport java.util.*;\nimport java.util.regex.Pattern;\n\npublic class OpenJDKTag {\n    private final Tag tag;\n    private final String prefix;\n    private final String version;\n    private final String buildPrefix;\n    private final String buildNum;\n\n    private OpenJDKTag(Tag tag, String prefix, String version, String buildPrefix, String buildNum) {\n        this.tag = tag;\n        this.prefix = prefix;\n        this.version = version;\n        this.buildPrefix = buildPrefix;\n        this.buildNum = buildNum;\n    }\n\n    /**\n     * The patterns have the following groups:\n     *\n     *                     prefix       version   buildPrefix  buildNum\n     *                     -------      -------   -----------  ------\n     * jdk-9.1+27       -> jdk-9.1      9.1       +            27\n     * jdk8-b90         -> jdk8         8         -b           90\n     * jdk7u40-b20      -> jdk7u40      7u40      -b           29\n     * hs24-b30         -> hs24         24        -b           30\n     * hs23.6-b19       -> hs23.6       23.6      -b           19\n     * 11.1+22          -> 11.1         11.1      +            22\n     * 8u321-b03        -> 8u321        8u321     -b           3\n     * jdk8u341-foo-b17 -> jdk8u341-foo 8u341-foo -b           17\n     * foo8u341-b17     -> foo8u341     foo8u341  -b           17\n     */\n\n    private final static String legacyOpenJDKVersionPattern = \"(jdk([0-9]{1,2}(u[0-9]{1,3}(?:-[a-z0-9]+)?)?))\";\n    private final static String legacyHSVersionPattern = \"((hs[0-9]{1,2}(\\\\.[0-9]{1,3})?))\";\n    private final static String legacyBuildPattern = \"(-b)([0-9]{2,3})\";\n    // Version pattern matching project Verona (JEP 223) based versions\n    private final static String veronaVersionPattern = \"((?:jdk-){0,1}([1-9](?:(?:[0-9]*)(\\\\.(?:0|[1-9][0-9]*)){0,6})))(?:(\\\\+)([0-9]+)|(-ga))\";\n    private final static String legacyOpenJFXVersionPattern = \"(([0-9](u[0-9]{1,3})?))\";\n    private final static String legacyOpenJDKProjectVersionPattern = \"(([a-z]+[0-9]{1,2}(u[0-9]{1,3}(?:-[a-z0-9]+)?)?))\";\n\n    private final static List<Pattern> tagPatterns = List.of(Pattern.compile(legacyOpenJDKVersionPattern + legacyBuildPattern),\n                                                             Pattern.compile(legacyHSVersionPattern + legacyBuildPattern),\n                                                             Pattern.compile(veronaVersionPattern),\n                                                             Pattern.compile(legacyOpenJFXVersionPattern + legacyBuildPattern),\n                                                             Pattern.compile(legacyOpenJDKProjectVersionPattern + legacyBuildPattern));\n\n    /**\n     * Attempts to create an OpenJDKTag instance from a general Tag.\n     *\n     * This will succeed if the tag follows the OpenJDK tag formatting\n     * conventions.\n     * @param tag\n     * @return\n     */\n    public static Optional<OpenJDKTag> create(Tag tag) {\n        for (var pattern : tagPatterns) {\n            var matcher = pattern.matcher(tag.name());\n            if (matcher.matches()) {\n                return Optional.of(new OpenJDKTag(tag, matcher.group(1), matcher.group(2), matcher.group(4), matcher.group(5)));\n            }\n        }\n\n        return Optional.empty();\n    }\n\n    /**\n     * The original Tag this OpenJDKTag was created from.\n     *\n     * @return\n     */\n    public Tag tag() {\n        return tag;\n    }\n\n    /**\n     * Version number, such as 11, 9.1, 8, 7u20.\n     *\n     * @return\n     */\n    public String version() {\n        return version;\n    }\n\n    /**\n     * The complete prefix, which is everything except the build number and any\n     * delimiter before it (e.g. jdk8u20, shenandoah8u332, jdk8u333-foo)\n     *\n     * @return\n     */\n    public String prefix() {\n        return prefix;\n    }\n\n    /**\n     * Build number.\n     *\n     * @return\n     */\n    public Optional<Integer> buildNum() {\n        if (buildNum == null) {\n            return Optional.empty();\n        }\n        return Optional.of(Integer.parseInt(buildNum));\n    }\n\n    /**\n     * Tag of the previous build (if any). Build 0 (and no build number at all) have no previous build.\n     *\n     * @return\n     */\n    public Optional<OpenJDKTag> previous() {\n        if (buildNum().orElse(0) == 0) {\n            return Optional.empty();\n        }\n\n        // Make sure build numbers < 10 for JDK 9 tags are not prefixed with '0'\n        var previousBuildNum = buildNum().get() - 1;\n        var formattedBuildNum = String.format(buildPrefix.equals(\"+\") ? \"%d\" : \"%02d\", previousBuildNum);\n        var tagName = prefix + buildPrefix + formattedBuildNum;\n        var tag = new Tag(tagName);\n        return create(tag);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (this == o) {\n            return true;\n        }\n        if (o == null || getClass() != o.getClass()) {\n            return false;\n        }\n        OpenJDKTag that = (OpenJDKTag) o;\n        return tag.equals(that.tag) &&\n                prefix.equals(that.prefix) &&\n                version.equals(that.version) &&\n                buildPrefix.equals(that.buildPrefix) &&\n                buildNum.equals(that.buildNum);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(tag, prefix, version, buildPrefix, buildNum);\n    }\n\n    @Override\n    public String toString() {\n        return tag.toString();\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/convert/Attribution.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk.convert;\n\nimport org.openjdk.skara.vcs.Author;\nimport java.util.List;\n\nclass Attribution {\n    private final Author author;\n    private final Author committer;\n    private final List<Author> contributors;\n\n    public Attribution(Author author, Author committer, List<Author> contributors) {\n        this.author = author;\n        this.committer = committer;\n        this.contributors = contributors;\n    }\n\n    public Author author() {\n        return author;\n    }\n\n    public Author committer() {\n        return committer;\n    }\n\n    public List<Author> contributors() {\n        return contributors;\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/convert/Converter.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk.convert;\n\nimport org.openjdk.skara.vcs.Repository;\nimport org.openjdk.skara.vcs.ReadOnlyRepository;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.util.List;\n\npublic interface Converter {\n    List<Mark> convert(ReadOnlyRepository from, Repository to) throws IOException;\n    List<Mark> pull(Repository gitRepo, URI source, Repository hgRepo, List<Mark> oldMarks) throws IOException;\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/convert/ConverterCommitMessageParser.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk.convert;\n\nimport java.util.List;\nimport java.util.ArrayList;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.*;\n\nimport static org.openjdk.skara.vcs.openjdk.CommitMessageSyntax.*;\n\npublic class ConverterCommitMessageParser implements CommitMessageParser {\n    public CommitMessage parse(List<String> message) {\n        var issues = new ArrayList<Issue>();\n        var reviewers = new ArrayList<String>();\n        var contributors = new ArrayList<Author>();\n        var summaries = new ArrayList<String>();\n        var additional = new ArrayList<String>();\n\n        for (var line : message) {\n            var m = ISSUE_PATTERN.matcher(line);\n            if (m.matches()) {\n                var id = m.group(1);\n                var desc = m.group(2);\n                issues.add(new Issue(id, desc));\n                continue;\n            }\n\n            m = REVIEWED_BY_PATTERN.matcher(line);\n            if (m.matches()) {\n                for (var name : m.group(1).split(\", \")) {\n                    reviewers.add(name);\n                }\n                continue;\n            }\n\n            m = SUMMARY_PATTERN.matcher(line);\n            if (m.matches()) {\n                summaries.add(m.group(1));\n                continue;\n            }\n\n            m = CONTRIBUTED_BY_PATTERN.matcher(line);\n            if (m.matches()) {\n                for (var attribution : m.group(1).split(\", \")) {\n                    if (attribution.contains(\" \")) {\n                        // must be 'Real Name <email>' variant\n                        contributors.add(Author.fromString(attribution));\n                    } else {\n                        // must be the email only variant\n                        contributors.add(new Author(\"\", attribution));\n                    }\n                }\n                continue;\n            }\n\n            additional.add(line);\n        }\n\n        return new CommitMessage(null, issues, reviewers, contributors, summaries, null, List.of(), additional);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/convert/GitCommitMetadata.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk.convert;\n\nimport org.openjdk.skara.vcs.*;\n\nimport java.time.*;\nimport java.util.List;\n\nclass GitCommitMetadata {\n    private final int mark;\n    private final List<Integer> parents;\n\n    private final Author author;\n    private final Author committer;\n\n    private final Branch branch;\n\n    private final ZonedDateTime date;\n\n    private final String message;\n\n    public GitCommitMetadata(int mark,\n                             List<Integer> parents,\n                             Author author,\n                             Author committer,\n                             Branch branch,\n                             ZonedDateTime date,\n                             String message) {\n        this.mark = mark;\n        this.parents = parents;\n        this.author = author;\n        this.committer = committer;\n        this.branch = branch;\n        this.date = date;\n        this.message = message;\n    }\n\n    public int mark() {\n        return mark;\n    }\n\n    public List<Integer> parents() {\n        return parents;\n    }\n\n    public Author author() {\n        return author;\n    }\n\n    public Author committer() {\n        return committer;\n    }\n\n    public Branch branch() {\n        return branch;\n    }\n\n    public ZonedDateTime date() {\n        return date;\n    }\n\n    public String message() {\n        return message;\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/convert/GitToHgConverter.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk.convert;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.*;\nimport java.io.IOException;\nimport java.nio.file.*;\nimport java.nio.charset.StandardCharsets;\nimport java.net.URI;\nimport java.nio.file.attribute.PosixFilePermissions;\nimport java.util.*;\nimport java.util.stream.Collectors;\nimport java.util.logging.Logger;\n\npublic class GitToHgConverter implements Converter {\n    private final static Logger log = Logger.getLogger(\"org.openjdk.skara.vcs.openjdk.convert\");\n    private final Branch branch;\n    private final List<Mark> marks = new ArrayList<>();\n\n    public GitToHgConverter() {\n        this(Branch.defaultFor(VCS.GIT));\n    }\n\n    public GitToHgConverter(Branch branch) {\n        this.branch = branch;\n    }\n\n    private String convertMessage(CommitMessage message, Author author, Author committer) {\n        var sb = new StringBuilder();\n        sb.append(message.title());\n        sb.append(\"\\n\");\n\n        var summaries = message.summaries();\n        if (!summaries.isEmpty()) {\n            sb.append(\"Summary: \");\n            sb.append(String.join(\" \", summaries));\n            sb.append(\"\\n\");\n        }\n\n        var reviewers = message.reviewers();\n        if (!reviewers.isEmpty()) {\n            sb.append(\"Reviewed-by: \");\n            sb.append(String.join(\", \", reviewers));\n            sb.append(\"\\n\");\n        }\n\n        var contributors = new ArrayList<String>();\n        if (!author.equals(committer)) {\n            contributors.add(author.toString());\n        }\n        contributors.addAll(message.contributors().stream().map(Author::toString).collect(Collectors.toList()));\n        if (!contributors.isEmpty()) {\n            sb.append(\"Contributed-by: \");\n            sb.append(String.join(\", \", contributors));\n            sb.append(\"\\n\");\n        }\n\n        return sb.toString();\n    }\n\n    private String username(Author committer) {\n        return committer.email().split(\"@\")[0];\n    }\n\n    private byte[] updateTags(ReadOnlyRepository gitRepo, Map<Hash, Hash> gitToHg, byte[] content) throws IOException {\n        var lines = new String(content, StandardCharsets.UTF_8).split(\"\\n\");\n        var result = new StringBuilder();\n        for (var line : lines) {\n            var parts = line.split(\" \");\n            var hash = parts[0];\n            if (hash.equals(Hash.zero().hex())) {\n                result.append(line);\n            } else {\n                var tag = parts[1];\n                var gitHash = gitRepo.resolve(tag);\n                if (gitHash.isPresent()) {\n                    var newHgHash = gitToHg.get(gitHash.get());\n                    if (newHgHash != null) {\n                        log.finer(\"updating tag: \" + tag + \" -> \" + newHgHash);\n                        result.append(newHgHash.hex() + \" \" + tag);\n                    } else {\n                        log.warning(\"Did not hg hash for git hash \" + gitHash.get() + \" for tag \" + tag);\n                        result.append(line);\n                    }\n                } else {\n                    // can be an old tag that has been removed, just add it, will be removed later\n                    log.warning(\"Did not find tag \" + tag + \" in git repo\");\n                    result.append(line);\n                }\n            }\n            result.append(\"\\n\");\n        }\n\n        return result.toString().getBytes(StandardCharsets.UTF_8);\n    }\n\n    private void apply(ReadOnlyRepository gitRepo, Path gitRoot, Repository hgRepo, Path hgRoot, List<StatusEntry> entries, Hash to) throws IOException {\n        var toRemove = new ArrayList<Path>();\n        var toAdd = new ArrayList<Path>();\n        var toDump = new ArrayList<Path>();\n\n        for (var entry : entries) {\n            var status = entry.status();\n            if (status.isDeleted()) {\n                toRemove.add(entry.source().path().orElseThrow());\n            } else if (status.isRenamed()) {\n                hgRepo.move(entry.source().path().orElseThrow(), entry.target().path().orElseThrow());\n                toDump.add(entry.target().path().orElseThrow());\n            } else if (status.isCopied()) {\n                hgRepo.copy(entry.source().path().orElseThrow(), entry.target().path().orElseThrow());\n                toDump.add(entry.target().path().orElseThrow());\n            } else if (status.isModified() || status.isAdded()) {\n                var targetPath = entry.target().path().orElseThrow();\n                toDump.add(targetPath);\n                if (status.isAdded()) {\n                    toAdd.add(targetPath);\n                }\n            } else {\n                throw new IOException(\"Unexpected status: \" + status.toString());\n            }\n        }\n\n        if (toDump.size() > 0) {\n            for (var file : gitRepo.files(to, toDump)) {\n                var hgPath = hgRoot.resolve(file.path());\n                gitRepo.dump(file, hgPath);\n                if (hgPath.getFileSystem().supportedFileAttributeViews().contains(\"posix\")) {\n                    Files.setPosixFilePermissions(hgPath, file.type().permissions().orElseThrow());\n                }\n            }\n        }\n\n        if (toAdd.size() > 0) {\n            hgRepo.add(toAdd);\n        }\n\n        if (toRemove.size() > 0) {\n            hgRepo.remove(toRemove);\n        }\n    }\n\n    private boolean changesHgTags(List<StatusEntry> status) {\n        var hgtags = Optional.of(Path.of(\".hgtags\"));\n        return status.stream()\n                     .filter(e -> e.status().isModified() || e.status().isAdded())\n                     .anyMatch(e -> e.target().path().equals(hgtags));\n    }\n\n    private Hash hgHashFor(Map<Hash, Hash> gitToHg, Hash gitHash) {\n        var hgHash = gitToHg.get(gitHash);\n        if (hgHash == null) {\n            throw new IllegalArgumentException(\"No known hg hash for git hash: \" + gitHash.hex());\n        }\n        return hgHash;\n    }\n\n    private void convertTags(Repository hgRepo, ReadOnlyRepository gitRepo, Map<Hash, Hash> gitToHg) throws IOException {\n        var gitTags = new TreeSet<String>();\n        for (var tag : gitRepo.tags()) {\n            gitTags.add(tag.name());\n        }\n        var hgTags = new TreeSet<String>();\n        for (var tag : hgRepo.tags()) {\n            hgTags.add(tag.name());\n        }\n        var missing = new TreeSet<>(gitTags);\n        missing.removeAll(hgTags);\n        var gitBranchHead = gitRepo.resolve(branch).orElseThrow(() ->\n            new IOException(\"Cannot resolve Git branch \" + branch)\n        );\n        for (var name : missing) {\n            log.info(\"Converting tag \" + name);\n            var gitHash = gitRepo.resolve(name).orElseThrow(() ->\n                    new IOException(\"Cannot resolve known tag \" + name)\n            );\n            if (!gitRepo.isAncestor(gitHash, gitBranchHead)) {\n                log.info(\"Tag \" + name + \" refers to a commit not present on branch \" + branch.name());\n                continue;\n            }\n            var hgHash = gitToHg.get(gitHash);\n            var annotated = gitRepo.annotate(new Tag(name));\n            if (annotated.isPresent()) {\n                var msg = annotated.get().message();\n                var user = username(annotated.get().author());\n                var date = annotated.get().date();\n                hgRepo.tag(hgHash, name, msg, user, null, date);\n            } else {\n                hgRepo.tag(hgHash, name, \"Added tag \" + name + \" for \" + hgHash.abbreviate(), \"duke\", null, null);\n            }\n            var hgTagCommitHash = hgRepo.head();\n            log.info(\"Converted tag \" + name + \" with resulting hash \" + hgTagCommitHash.hex());\n            var last = marks.get(marks.size() - 1);\n            var newMark = new Mark(last.key(), last.hg(), last.git(), hgTagCommitHash);\n            marks.set(marks.size() - 1, newMark);\n        }\n    }\n\n    private void convert(List<CommitMetadata> commits,\n                         Repository hgRepo,\n                         ReadOnlyRepository gitRepo,\n                         Map<Hash, Hash> gitToHg) throws IOException {\n        var hgRoot = hgRepo.root();\n        var gitRoot = gitRepo.root();\n\n        for (var commit : commits) {\n            log.info(\"Converting Git hash: \" + commit.hash().hex());\n            var parents = commit.parents();\n            var gitParent0 = parents.get(0);\n            var status0 = gitRepo.status(gitParent0, commit.hash());\n\n            if (commit.isInitialCommit()) {\n                apply(gitRepo, gitRoot, hgRepo, hgRoot, status0, commit.hash());\n            } else if (parents.size() == 1) {\n                var hgParent0 = hgHashFor(gitToHg, gitParent0);\n                log.fine(\"Parent 0:\" + hgParent0.hex());\n                hgRepo.checkout(hgParent0, false);\n                apply(gitRepo, gitRoot, hgRepo, hgRoot, status0, commit.hash());\n            } else if (parents.size() == 2) {\n                var hgParent0 = hgHashFor(gitToHg, gitParent0);\n                log.fine(\"Parent 0:\" + hgParent0.hex());\n                hgRepo.checkout(hgParent0, false);\n\n                var hgParent1 = hgHashFor(gitToHg, parents.get(1));\n                log.fine(\"Parent 1:\" + hgParent1.hex());\n                hgRepo.merge(hgParent1, \":local\", Repository.FastForward.DISABLE);\n                hgRepo.revert(hgParent0);\n                hgRepo.deleteUntrackedFiles();\n\n                apply(gitRepo, gitRoot, hgRepo, hgRoot, status0, commit.hash());\n            } else {\n                throw new IllegalStateException(\"Merging more than two parents is not supported in Mercurial\");\n            }\n\n            if (changesHgTags(status0)) {\n                var content = gitRepo.show(Path.of(\".hgtags\"), commit.hash()).orElseThrow();\n                var adjustedContent = updateTags(gitRepo, gitToHg, content);\n                Files.write(hgRoot.resolve(\".hgtags\"), adjustedContent, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);\n            }\n\n            var author = commit.author();\n            var committer = commit.committer();\n            if (committer.email() == null) {\n                throw new IllegalStateException(\"Commit \" + commit.hash().hex() + \" contains committer without email\");\n            }\n            var message = CommitMessageParsers.v1.parse(commit.message());\n            var hgMessage = convertMessage(message, author, committer);\n            log.finest(\"Message: \" + hgMessage);\n            var hgAuthor = username(committer);\n            log.finer(\"Author: \" + hgAuthor);\n            var date = commit.authored();\n            log.finer(\"Date: \" + date);\n\n            Hash hgHash = null;\n            if (parents.size() == 1 && status0.isEmpty()) {\n                var tmp = Files.createFile(hgRoot.resolve(\"THIS_IS_A_REALLY_UNIQUE_FILE_NAME_THAT_CANT_POSSIBLY_BE_USED\"));\n                hgRepo.add(tmp);\n                hgRepo.commit(hgMessage, hgAuthor, null, date);\n                hgRepo.remove(tmp);\n                hgHash = hgRepo.amend(hgMessage, hgAuthor, null);\n            } else {\n                hgHash = hgRepo.commit(hgMessage, hgAuthor, null, date);\n            }\n            log.info(\"Converted hg hash: \" + hgHash.hex());\n\n            marks.add(new Mark(marks.size() + 1, hgHash, commit.hash()));\n            gitToHg.put(commit.hash(), hgHash);\n        }\n\n        convertTags(hgRepo, gitRepo, gitToHg);\n    }\n\n    public List<Mark> marks() {\n        return marks;\n    }\n\n    public List<Mark> convert(ReadOnlyRepository gitRepo, Repository hgRepo) throws IOException {\n        return convert(gitRepo, hgRepo, List.of());\n    }\n\n    public List<Mark> convert(ReadOnlyRepository gitRepo, Repository hgRepo, List<Mark> oldMarks) throws IOException {\n        var gitToHg = new HashMap<Hash, Hash>();\n        for (var mark : oldMarks) {\n            if (mark.tag().isPresent()) {\n                gitToHg.put(mark.git(), mark.tag().get());\n            } else {\n                gitToHg.put(mark.git(), mark.hg());\n            }\n            marks.add(mark);\n        }\n        var gitCommits = gitRepo.commitMetadata(branch.name(), true);\n        var converted = oldMarks.stream()\n                                .map(Mark::git)\n                                .collect(Collectors.toSet());\n        var notConverted = gitCommits.stream()\n                                     .filter(c -> !converted.contains(c.hash()))\n                                     .collect(Collectors.toList());\n        convert(notConverted, hgRepo, gitRepo, gitToHg);\n        return marks;\n    }\n\n    public List<Mark> pull(Repository gitRepo, URI source, Repository hgRepo, List<Mark> oldMarks) throws IOException {\n        var gitToHg = new HashMap<Hash, Hash>();\n        for (var mark : oldMarks) {\n            if (mark.tag().isPresent()) {\n                gitToHg.put(mark.git(), mark.tag().get());\n            } else {\n                gitToHg.put(mark.git(), mark.hg());\n            }\n            marks.add(mark);\n        }\n\n        gitRepo.checkout(branch);\n        var oldHead = gitRepo.head();\n        gitRepo.pull(source.toString(), branch.name());\n        var newHead = gitRepo.head();\n        var commits = gitRepo.commitMetadata(gitRepo.rangeInclusive(oldHead, newHead), true);\n        convert(commits, hgRepo, gitRepo, gitToHg);\n        return marks;\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/convert/HgToGitConverter.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk.convert;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.*;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.*;\nimport java.time.ZonedDateTime;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\nimport java.util.concurrent.TimeUnit;\nimport java.util.function.Function;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\n\nimport static java.lang.Integer.parseInt;\n\npublic class HgToGitConverter implements Converter {\n    private static class ProcessInfo implements AutoCloseable {\n        private final java.lang.Process process;\n        private final List<String> command;\n        private final Path stdout;\n        private final Path stderr;\n        private final CloseAction closeAction;\n\n        @FunctionalInterface\n        interface CloseAction {\n            void close() throws IOException;\n        }\n\n        ProcessInfo(java.lang.Process process, List<String> command, Path stdout, Path stderr, CloseAction closeAction) {\n            this.process = process;\n            this.command = command;\n            this.stdout = stdout;\n            this.stderr = stderr;\n            this.closeAction = closeAction;\n        }\n\n        ProcessInfo(java.lang.Process process, List<String> command, Path stdout, Path stderr) {\n            this(process, command, stdout, stderr, () -> {});\n        }\n\n        java.lang.Process process() {\n            return process;\n        }\n\n        List<String> command() {\n            return command;\n        }\n\n        Path stdout() {\n            return stdout;\n        }\n\n        Path stderr() {\n            return stderr;\n        }\n\n        int waitForProcess() throws InterruptedException {\n            var finished = process.waitFor(12, TimeUnit.HOURS);\n            if (!finished) {\n                process.destroyForcibly().waitFor();\n                throw new RuntimeException(\"Command '\" + String.join(\" \", command) + \"' did not finish in 12 hours\");\n            }\n            return process.exitValue();\n        }\n\n        @Override\n        public void close() throws IOException {\n            if (stdout != null) {\n                Files.delete(stdout);\n            }\n            if (stderr != null) {\n                Files.delete(stderr);\n            }\n            closeAction.close();\n        }\n    }\n\n    private final byte[] fileBuffer = new byte[2048];\n    private final Logger log = Logger.getLogger(\"org.openjdk.skara.vcs.openjdk.convert\");\n\n    private final Map<Hash, List<String>> replacements;\n    private final Map<Hash, Map<String, String>> corrections;\n    private final Set<Hash> lowercase;\n    private final Set<Hash> punctuated;\n\n    private final Map<String, String> authorMap;\n    private final Map<String, String> contributorMap;\n    private final Map<String, List<String>> sponsorMap;\n\n    private final CommitMessageParser parser = new ConverterCommitMessageParser();\n    private int currentMark = 0;\n    private final Map<Hash, Integer> hgHashesToMarks = new HashMap<>();\n    private final Map<Integer, Hash> marksToHgHashes = new HashMap<>();\n\n    public HgToGitConverter(Map<Hash, List<String>> replacements,\n                            Map<Hash, Map<String, String>> corrections,\n                            Set<Hash> lowercase,\n                            Set<Hash> punctuated,\n                            Map<String, String> authorMap,\n                            Map<String, String> contributorMap,\n                            Map<String, List<String>> sponsorMap) {\n        this.replacements = replacements;\n        this.corrections = corrections;\n        this.lowercase = lowercase;\n        this.punctuated = punctuated;\n\n        this.authorMap = authorMap;\n        this.contributorMap = contributorMap;\n        this.sponsorMap = sponsorMap;\n    }\n\n    private static Branch convertBranch(Branch branch) {\n        if (branch.equals(Branch.defaultFor(VCS.HG))) {\n            return Branch.defaultFor(VCS.GIT);\n        }\n\n        return branch;\n    }\n\n    private static String convertFlags(String flags) {\n        if (flags.contains(\"x\")) {\n            return \"100755\";\n        }\n\n        if (flags.contains(\"l\")) {\n            return \"120000\";\n        }\n\n        return \"100644\";\n    }\n\n    private static String capitalize(String s) {\n        return s.substring(0, 1).toUpperCase() + s.substring(1);\n    }\n\n    private static String removePunctuation(String s) {\n        return s.endsWith(\".\") ? s.substring(0, s.length() - 1) : s;\n    }\n\n    private int nextMark(Hash hgHash) {\n        currentMark++;\n        var next = currentMark;\n        hgHashesToMarks.put(hgHash, next);\n        marksToHgHashes.put(next, hgHash);\n        return next;\n    }\n\n    private Author convertAuthor(Author from) {\n        var author = authorMap.get(from.name());\n        if (author == null) {\n            throw new RuntimeException(\"Failed to find author mapping for: \" + from.name());\n        }\n        return Author.fromString(author);\n    }\n\n    private Attribution attribute(List<Author> contributorsFromCommit, Author hgAuthor) {\n        var isSponsored = false;\n        var contributors = new ArrayList<>(contributorsFromCommit);\n        if (contributors.size() == 1) {\n            isSponsored = true;\n        } else if (contributors.size() > 1) {\n            // The author has sponsored at least one commit, see if this commit was sponsored.\n            // The commit is sponsored if the author is *not* listed on the \"Contributed-by\" line.\n\n            var emails = sponsorMap.get(hgAuthor.name());\n            if (emails == null) {\n                throw new RuntimeException(\"Failed to find sponsor mapping for: \" + hgAuthor.name());\n            }\n            Author authorAsContributor = null;\n            for (var email : emails) {\n                for (var contributor : contributors) {\n                    if (contributor.email().equals(email)) {\n                        authorAsContributor = contributor;\n                        break;\n                    }\n                }\n            }\n            if (authorAsContributor != null) {\n                contributors.remove(authorAsContributor);\n            } else {\n                isSponsored = true;\n            }\n        }\n\n        var originalAuthor = convertAuthor(hgAuthor);\n\n        Author author = null;\n        if (isSponsored) {\n            author = new Author(contributors.get(0).name(), contributors.get(0).email());\n            contributors.remove(0);\n        } else {\n            author = originalAuthor;\n        }\n        var committer = isSponsored ? originalAuthor : author;\n\n        return new Attribution(author, committer, contributors);\n    }\n\n    private List<Author> addContributorNames(List<Author> contributors) {\n        final Function<Author, String> lookup = (Author a) -> {\n            var author = contributorMap.get(a.email());\n            if (author == null) {\n                throw new RuntimeException(\"Failed to find contributor mapping for: \" + a.email());\n            }\n            return author;\n        };\n        return contributors.stream()\n                           .map(a -> a.name().isEmpty() ? Author.fromString(lookup.apply(a)) : a)\n                           .collect(Collectors.toList());\n    }\n\n    private static List<String> cleanup(List<String> original, Map<String, String> corrections) {\n        if (corrections == null) {\n            return original;\n        }\n\n        return original.stream().map(l -> corrections.getOrDefault(l, l)).collect(Collectors.toList());\n    }\n\n    private String toGitCommitMessage(Hash hash, List<Issue> issues, List<String> summaries, List<Author> contributors, List<String> reviewers, List<String> others) {\n        List<String> body = new ArrayList<>();\n        body.addAll(summaries.stream().map(HgToGitConverter::capitalize).collect(Collectors.toList()));\n        body.addAll(others);\n\n        var subject = issues.stream().map(Issue::toString).collect(Collectors.toList());\n        if (subject.size() == 0) {\n            subject = body.subList(0, 1);\n            body = body.subList(1, body.size());\n        }\n\n        var firstNonNewlineIndex = 0;\n        while (firstNonNewlineIndex < body.size() && body.get(firstNonNewlineIndex).equals(\"\")) {\n            firstNonNewlineIndex++;\n        }\n        body = body.subList(firstNonNewlineIndex, body.size());\n\n        var sb = new StringBuilder();\n        for (var line : subject) {\n            line = lowercase.contains(hash) ? line : capitalize(line);\n            line = punctuated.contains(hash) ? line : removePunctuation(line);\n            if (line.startsWith(\"JMC-\")) {\n                line = line.substring(4);\n            }\n            sb.append(line);\n            sb.append(\"\\n\");\n        }\n        if ((body.size() + contributors.size() + reviewers.size()) > 0) {\n            sb.append(\"\\n\");\n        }\n\n        var hasPrintedNonNewline = false;\n        for (var line : body) {\n            // Remove any number of initial empty lines\n            if (!hasPrintedNonNewline && line.equals(\"\")) {\n                continue;\n            }\n            sb.append(line);\n            sb.append(\"\\n\");\n            hasPrintedNonNewline = true;\n        }\n        if (body.size() > 0) {\n            sb.append(\"\\n\");\n        }\n\n        for (var contributor : contributors) {\n            sb.append(\"Co-authored-by: \");\n            sb.append(contributor.toString());\n            sb.append(\"\\n\");\n        }\n\n        if (reviewers.size() > 0) {\n            sb.append(\"Reviewed-by: \");\n            sb.append(String.join(\", \", reviewers));\n            sb.append(\"\\n\");\n        }\n\n        return sb.toString();\n    }\n\n    private GitCommitMetadata convertMetadata(Hash hgHash,\n                                              Branch hgBranch,\n                                              Author hgAuthor,\n                                              List<Hash> hgParentHashes,\n                                              ZonedDateTime hgDate,\n                                              List<String> hgCommitMessage) {\n        var shortHash = new Hash(hgHash.hex().substring(0, 12));\n\n        hgCommitMessage = replacements.getOrDefault(shortHash, hgCommitMessage);\n        hgCommitMessage = cleanup(hgCommitMessage, corrections.get(shortHash));\n\n        var commitMessage = parser.parse(hgCommitMessage);\n        var hgContributors = addContributorNames(commitMessage.contributors());\n\n        var attribution = attribute(hgContributors, hgAuthor);\n        var gitAuthor = attribution.author();\n        var gitCommitter = attribution.committer();\n        var gitMessage = toGitCommitMessage(shortHash,\n                                            commitMessage.issues(),\n                                            commitMessage.summaries(),\n                                            attribution.contributors(),\n                                            commitMessage.reviewers(),\n                                            commitMessage.additional());\n\n        var gitMark = nextMark(hgHash);\n        var gitParentMarks = hgParentHashes.stream().map(hgHashesToMarks::get).collect(Collectors.toList());\n\n        var gitBranch = convertBranch(hgBranch);\n        var gitDate = hgDate; // no conversion needed\n\n        return new GitCommitMetadata(gitMark, gitParentMarks, gitAuthor, gitCommitter, gitBranch, gitDate, gitMessage);\n    }\n\n    private List<Hash> convertCommits(Pipe pipe) throws IOException {\n        var tagCommits = new ArrayList<Hash>();\n        while (pipe.read() != -1) {\n            pipe.readln(); // skip delimiter\n            var hash = new Hash(pipe.readln());\n            log.fine(\"Converting: \" + hash.hex());\n            pipe.readln(); // skip revision number\n            var branch = new Branch(pipe.readln());\n            log.finer(\"Branch: \" + branch.name());\n\n            var parents = pipe.readln();\n            log.finer(\"Parents: \" + parents);\n            var parentHashes = Arrays.asList(parents.split(\" \"))\n                                     .stream()\n                                     .map(Hash::new)\n                                     .collect(Collectors.toList());\n            if (parentHashes.size() == 1 && parentHashes.get(0).equals(Hash.zero())) {\n                parentHashes = new ArrayList<>();\n            }\n            pipe.readln(); // skip parent revisions\n\n            var author = Author.fromString(pipe.readln());\n            log.finer(\"Author: \" + author.toString());\n\n            var formatter = DateTimeFormatter.ofPattern(\"yyyy-MM-dd H:m:sZ\");\n            var date = ZonedDateTime.parse(pipe.readln(), formatter);\n            log.finer(\"Date: \" + date.toString());\n\n            var messageSize = parseInt(pipe.readln());\n            var messageBuffer = pipe.read(messageSize);\n            var hgMessage = new String(messageBuffer, 0, messageSize, StandardCharsets.UTF_8);\n            log.finest(\"Message: \" + hgMessage);\n\n            var metadata = convertMetadata(hash,\n                                           branch,\n                                           author,\n                                           parentHashes,\n                                           date,\n                                           Arrays.asList(hgMessage.split(\"\\n\")));\n\n            pipe.print(\"commit refs/heads/\");\n            pipe.println(metadata.branch().name());\n\n            pipe.print(\"mark :\");\n            pipe.println(metadata.mark());\n\n            var epoch = metadata.date().toEpochSecond();\n            var offset = metadata.date().format(DateTimeFormatter.ofPattern(\"xx\"));\n\n            pipe.print(\"author \");\n            pipe.print(metadata.author().name());\n            pipe.print(\" <\");\n            pipe.print(metadata.author().email());\n            pipe.print(\"> \");\n            pipe.print(epoch);\n            pipe.print(\" \");\n            pipe.println(offset);\n\n            pipe.print(\"committer \");\n            pipe.print(metadata.committer().name());\n            pipe.print(\" <\");\n            pipe.print(metadata.committer().email());\n            pipe.print(\"> \");\n            pipe.print(epoch);\n            pipe.print(\" \");\n            pipe.println(offset);\n\n            pipe.print(\"data \");\n\n            var gitMessage = metadata.message().getBytes(StandardCharsets.UTF_8);\n            pipe.println(gitMessage.length);\n            pipe.print(gitMessage);\n\n            if (metadata.parents().size() > 0) {\n                pipe.print(\"from :\");\n                pipe.println(metadata.parents().get(0));\n            }\n            if (metadata.parents().size() > 1) {\n                pipe.print(\"merge :\");\n                pipe.println(metadata.parents().get(1));\n            }\n\n            // Stream the file content\n            var numModified = parseInt(pipe.readln());\n            var numAdded = parseInt(pipe.readln());\n            var numRemoved = parseInt(pipe.readln());\n\n            for (var i = 0; i < (numAdded + numModified); i++) {\n                var filename = pipe.readln();\n                var flags = pipe.readln();\n\n                if (filename.equals(\".hgtags\") && parentHashes.size() == 1) {\n                    tagCommits.add(hash);\n                }\n\n                log.finest(\"M \" + filename);\n                pipe.print(\"M \");\n                pipe.print(convertFlags(flags));\n                pipe.print(\" inline \");\n                pipe.println(filename);\n\n                var numBytes = parseInt(pipe.readln());\n                pipe.print(\"data \");\n                pipe.println(numBytes);\n\n                var leftToRead = numBytes;\n                while (leftToRead != 0) {\n                    var numRead = pipe.read(fileBuffer, 0, Math.min(fileBuffer.length, leftToRead));\n                    if (numRead == -1) {\n                        throw new IOException(\"Unexpected end of input\");\n                    }\n                    pipe.print(fileBuffer, 0, numRead);\n                    leftToRead -= numRead;\n                }\n            }\n\n            for (var i = 0; i < numRemoved; i++) {\n                var filename = pipe.readln();\n                log.finest(\"D \" + filename);\n                pipe.print(\"D \");\n                pipe.println(filename);\n            }\n        }\n\n\n        return tagCommits;\n    }\n\n    private void convertTags(Pipe pipe, List<Hash> tagCommits, ReadOnlyRepository hgRepo) throws IOException {\n        var tags = new HashMap<String, Commit>();\n        for (var tagHash : tagCommits) {\n            log.fine(\"Inspecting tag commit \" + tagHash.toString());\n            var commit = hgRepo.lookup(tagHash).orElseThrow(() -> new IOException(\"Could not find commit \" + tagHash));\n            var diff = commit.parentDiffs().get(0); // convert never returns merge commits\n            for (var patch : diff.patches()) {\n                var target = patch.target().path();\n                if (target.isPresent() && target.get().equals(Path.of(\".hgtags\"))) {\n                    for (var hunk : patch.asTextualPatch().hunks()) {\n                        for (var line : hunk.target().lines()) {\n                            if (line.isEmpty()) {\n                                continue;\n                            }\n                            var parts = line.split(\" \");\n                            var hash = parts[0];\n                            var tag = parts[1];\n                            if (hash.equals(Hash.zero().hex())) {\n                                tags.remove(tag);\n                            } else {\n                                tags.put(tag, commit);\n                            }\n                        }\n                    }\n                }\n            }\n        }\n\n        for (var tag : hgRepo.tags()) {\n            if (tags.containsKey(tag.name())) {\n                var commit = tags.get(tag.name());\n\n                log.fine(\"Converting tag \" + tag.name());\n                pipe.print(\"tag \");\n                pipe.println(tag.name());\n                pipe.print(\"from :\");\n                pipe.println(hgHashesToMarks.get(hgRepo.lookup(tag).get().hash()));\n\n                pipe.print(\"tagger \");\n                var author = convertAuthor(commit.author());\n                pipe.print(author.name());\n                pipe.print(\" <\");\n                pipe.print(author.email());\n                pipe.print(\"> \");\n                var epoch = commit.authored().toEpochSecond();\n                var offset = commit.authored().format(DateTimeFormatter.ofPattern(\"xx\"));\n                pipe.print(epoch);\n                pipe.print(\" \");\n                pipe.println(offset);\n\n                pipe.print(\"data \");\n                var message = String.join(\"\\n\", commit.message());\n                var bytes = message.getBytes(StandardCharsets.UTF_8);\n                pipe.println(bytes.length);\n                pipe.print(bytes);\n            }\n        }\n    }\n\n    private List<Mark> readMarks(Path p) throws IOException {\n        var marks = new ArrayList<Mark>();\n        try (var reader = Files.newBufferedReader(p)) {\n            for (var line = reader.readLine(); line != null; line = reader.readLine()) {\n                var parts = line.split(\" \");\n                var mark = parseInt(parts[0].substring(1));\n                var gitHash = new Hash(parts[1]);\n                var hgHash = marksToHgHashes.get(mark);\n                log.finest(\"parsed mark \" + mark + \", hg: \" + hgHash.hex() + \", git: \" + gitHash.hex());\n                marks.add(new Mark(mark, hgHash, gitHash));\n            }\n        }\n        return marks;\n    }\n\n    private Path writeMarks(List<Mark> marks) throws IOException {\n        var gitMarks = Files.createTempFile(\"git\", \".marks.txt\");\n        try (var writer = Files.newBufferedWriter(gitMarks)) {\n            for (var mark : marks) {\n                writer.write(\":\");\n                writer.write(Integer.toString(mark.key()));\n                writer.write(\" \");\n                writer.write(mark.git().hex());\n                writer.newLine();\n\n                marksToHgHashes.put(mark.key(), mark.hg());\n                hgHashesToMarks.put(mark.hg(), mark.key());\n            }\n        }\n        return gitMarks;\n    }\n\n    private ProcessInfo dump(ReadOnlyRepository repo) throws IOException {\n        var ext = Files.createTempFile(\"ext\", \".py\");\n        Files.copy(this.getClass().getResourceAsStream(\"/ext.py\"), ext, StandardCopyOption.REPLACE_EXISTING);\n\n        var command = List.of(\"hg\", \"--config\", \"extensions.dump=\" + ext.toAbsolutePath().toString(), \"dump\");\n        var pb = new ProcessBuilder(command);\n        pb.environment().put(\"HGRCPATH\", \"\");\n        pb.environment().put(\"HGPLAIN\", \"\");\n        pb.directory(repo.root().toFile());\n\n        var stderr = Files.createTempFile(\"dump\", \".stderr.txt\");\n        pb.redirectError(stderr.toFile());\n        log.finer(\"hg dump stderr: \" + stderr.toString());\n\n        log.finer(\"Starting '\" + String.join(\" \", command) + \"'\");\n        return new ProcessInfo(pb.start(), command, null, stderr, () -> Files.delete(ext));\n    }\n\n    private ProcessInfo pull(Repository repo, URI source) throws IOException {\n        var ext = Files.createTempFile(\"ext\", \".py\");\n        var extStream = getClass().getResourceAsStream(\"/ext.py\");\n        if (extStream == null) {\n            // Used when running outside of the module path (such as from an IDE)\n            var classPath = Path.of(getClass().getProtectionDomain().getCodeSource().getLocation().getPath());\n            var extPath = classPath.getParent().resolve(\"resources\").resolve(\"ext.py\");\n            extStream = new FileInputStream(extPath.toFile());\n        }\n        Files.copy(extStream, ext, StandardCopyOption.REPLACE_EXISTING);\n\n        var hook = \"hooks.pretxnclose=python:\" + ext.toAbsolutePath().toString() + \":pretxnclose\";\n        var command = List.of(\"hg\", \"--config\", hook, \"pull\", \"--quiet\", source.toString());\n        var pb = new ProcessBuilder(command);\n        pb.environment().put(\"HGRCPATH\", \"\");\n        pb.environment().put(\"HGPLAIN\", \"\");\n        pb.directory(repo.root().toFile());\n\n        var stderr = Files.createTempFile(\"pull\", \".stderr.txt\");\n        pb.redirectError(stderr.toFile());\n\n        log.finer(\"Starting '\" + String.join(\" \", command) + \"'\");\n        return new ProcessInfo(pb.start(), command, null, stderr, () -> Files.delete(ext));\n    }\n\n    private ProcessInfo fastImport(ReadOnlyRepository repo) throws IOException {\n        var command = List.of(\"git\", \"fast-import\", \"--allow-unsafe-features\");\n        var pb = new ProcessBuilder(command);\n        pb.directory(repo.root().toFile());\n\n        var stdout = Files.createTempFile(\"fast-import\", \".stdout.txt\");\n        pb.redirectOutput(stdout.toFile());\n\n        var stderr = Files.createTempFile(\"fast-import\", \".stderr.txt\");\n        pb.redirectError(stderr.toFile());\n\n        log.finer(\"Starting '\" + String.join(\" \", command) + \"'\");\n        return new ProcessInfo(pb.start(), command, stdout, stderr);\n    }\n\n    private void await(ProcessInfo p) throws IOException {\n        try {\n            int res = p.waitForProcess();\n            if (res != 0) {\n                var msg = String.join(\" \", p.command()) + \" exited with status \" + res;\n                log.severe(msg);\n                throw new IOException(msg);\n            }\n        } catch (InterruptedException e) {\n            throw new IOException(e);\n        }\n    }\n\n    private void convert(ProcessInfo hg, ProcessInfo git, ReadOnlyRepository hgRepo, Path marks) throws IOException {\n        var pipe = new Pipe(hg.process().getInputStream(), git.process().getOutputStream(), 512);\n\n        pipe.println(\"feature done\");\n        pipe.println(\"feature import-marks-if-exists=\" + marks.toAbsolutePath().toString());\n        pipe.println(\"feature export-marks=\" + marks.toAbsolutePath().toString());\n\n        var tagCommits = convertCommits(pipe);\n        convertTags(pipe, tagCommits, hgRepo);\n\n        pipe.println(\"done\");\n    }\n\n    private void log(ProcessInfo hg, ProcessInfo git, Path gitRoot) throws IOException {\n        if (Files.exists(hg.stderr())) {\n            var content = Files.readString(hg.stderr());\n            if (!content.isEmpty()) {\n                log.warning(\"'\" + String.join(\" \", hg.command()) + \"' [stderr]: \" + content);\n            }\n        }\n\n        if (Files.exists(git.stdout())) {\n            var content = Files.readString(git.stdout());\n            if (!content.isEmpty()) {\n                log.warning(\"'\" + String.join(\" \", git.command()) + \"' [stdout]: \" + content);\n            }\n        }\n        if (Files.exists(git.stderr())) {\n            var content = Files.readString(git.stderr());\n            if (!content.isEmpty()) {\n                log.warning(\"'\" + String.join(\" \", git.command()) + \"' [stderr]: \" + content);\n            }\n        }\n\n        if (Files.isDirectory(gitRoot)) {\n            try (var paths = Files.walk(gitRoot)) {\n                for (var path : paths.toList()) {\n                    if (path.getFileName().toString().startsWith(\"fast_import_crash\")) {\n                        log.warning(Files.readString(path));\n                    }\n                }\n            }\n        }\n    }\n\n    public List<Mark> convert(ReadOnlyRepository hgRepo, Repository gitRepo) throws IOException {\n        try (var hg = dump(hgRepo);\n             var git = fastImport(gitRepo)) {\n            try {\n                var gitMarks = Files.createTempFile(\"git\", \".marks.txt\");\n                convert(hg, git, hgRepo, gitMarks);\n\n                await(git);\n                await(hg);\n\n                var ret = readMarks(gitMarks);\n                Files.delete(gitMarks);\n                return ret;\n            } catch (IOException e) {\n                log(hg, git, gitRepo.root());\n                throw e;\n            }\n        }\n    }\n\n    public List<Mark> pull(Repository hgRepo, URI source, Repository gitRepo, List<Mark> marks) throws IOException {\n        try (var hg = pull(hgRepo, source);\n             var git = fastImport(gitRepo)) {\n            try {\n                for (var mark : marks) {\n                    hgHashesToMarks.put(mark.hg(), mark.key());\n                    marksToHgHashes.put(mark.key(), mark.hg());\n                    currentMark = Math.max(mark.key(), currentMark);\n                }\n                var gitMarks = writeMarks(marks);\n                convert(hg, git, hgRepo, gitMarks);\n\n                await(git);\n                await(hg);\n\n                var ret = readMarks(gitMarks);\n                Files.delete(gitMarks);\n                return ret;\n            } catch (IOException e) {\n                log(hg, git, gitRepo.root());\n                throw e;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/convert/Mark.java",
    "content": "/*\n * Copyright (c) 2019, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk.convert;\n\nimport org.openjdk.skara.vcs.Hash;\nimport java.util.Objects;\nimport java.util.Optional;\nimport static java.util.Objects.equals;\n\npublic class Mark implements Comparable<Mark> {\n    private final int key;\n    private final Hash hg;\n    private final Hash git;\n    private final Hash tag;\n\n    public Mark(int key, Hash hg, Hash git) {\n        if (key == 0) {\n            throw new IllegalArgumentException(\"A mark cannot be 0\");\n        }\n        this.key = key;\n        this.hg = hg;\n        this.git = git;\n        this.tag = null;\n    }\n\n    public Mark(int key, Hash hg, Hash git, Hash tag) {\n        if (key == 0) {\n            throw new IllegalArgumentException(\"A mark cannot be 0\");\n        }\n        this.key = key;\n        this.hg = hg;\n        this.git = git;\n        this.tag = tag;\n    }\n\n    public int key() {\n        return key;\n    }\n\n    public Hash hg() {\n        return hg;\n    }\n\n    public Hash git() {\n        return git;\n    }\n\n    public Optional<Hash> tag() {\n        return Optional.ofNullable(tag);\n    }\n\n    @Override\n    public int compareTo(Mark o) {\n        return Integer.compare(key, o.key);\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(key, hg, git, tag);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (o == this) {\n            return true;\n        }\n\n        if (o instanceof Mark m) {\n            return Objects.equals(key, m.key) &&\n                   Objects.equals(hg, m.hg) &&\n                   Objects.equals(git, m.git) &&\n                   Objects.equals(tag, m.tag);\n        }\n\n        return false;\n    }\n\n    @Override\n    public String toString() {\n        return hg.hex() + \" \" + git.hex();\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/openjdk/convert/Pipe.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk.convert;\n\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.util.*;\nimport java.nio.charset.StandardCharsets;\n\nclass Pipe {\n    private final InputStream from;\n    private final OutputStream to;\n    private final byte[] lineBuffer;\n\n    Pipe(InputStream from, OutputStream to, int bufferSize) {\n        this.from = from;\n        this.to = to;\n        lineBuffer = new byte[bufferSize];\n    }\n\n    int read() throws IOException {\n        return from.read();\n    }\n\n    byte[] read(int n) throws IOException {\n        var result = new byte[n];\n        read(result);\n        return result;\n    }\n\n    void read(byte[] b) throws IOException {\n        var read = 0;\n        while (read != b.length) {\n            read += from.read(b, read, b.length - read);\n        }\n    }\n\n    int read(byte[] b, int offset, int length) throws IOException {\n        return from.read(b, offset, length);\n    }\n\n    String readln() throws IOException {\n        var index = 0;\n        var current = from.read();\n        while (current != (int) '\\n') {\n            if (index == lineBuffer.length) {\n                throw new IOException(\"Line too long: \" + new String(lineBuffer, 0, index, StandardCharsets.UTF_8));\n            }\n            lineBuffer[index] = (byte) current;\n            index++;\n            current = from.read();\n        }\n        return new String(lineBuffer, 0, index, StandardCharsets.UTF_8);\n    }\n\n    void print(String s) throws IOException {\n        to.write(s.getBytes(StandardCharsets.UTF_8));\n    }\n\n    void print(long l) throws IOException {\n        print(Long.toString(l));\n    }\n\n    void println(String s) throws IOException {\n        print(s);\n        print(\"\\n\");\n        to.flush();\n    }\n\n    void print(byte[] bytes) throws IOException {\n        to.write(bytes);\n    }\n\n    void print(byte[] bytes, int offset, int length) throws IOException {\n        to.write(bytes, offset, length);\n    }\n\n    void println(byte[] bytes) throws IOException {\n        print(bytes);\n        print(\"\\n\");\n        to.flush();\n    }\n\n    void println(int i) throws IOException {\n        println(Integer.toString(i));\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/tools/GitRawDiffParser.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.tools;\n\nimport org.openjdk.skara.encoding.Base85;\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.nio.charset.StandardCharsets;\nimport java.util.*;\nimport java.util.zip.Inflater;\nimport java.util.zip.DataFormatException;\n\npublic class GitRawDiffParser {\n    private static class Hunks {\n        private final List<Hunk> textual;\n        private final List<BinaryHunk> binary;\n\n        private Hunks(List<Hunk> textual, List<BinaryHunk> binary) {\n            this.textual = textual;\n            this.binary = binary;\n        }\n\n        static Hunks ofTextual(List<Hunk> textual) {\n            return new Hunks(textual, null);\n        }\n\n        static Hunks ofBinary(List<BinaryHunk> binary) {\n            return new Hunks(null, binary);\n        }\n\n        boolean areBinary() {\n            return binary != null;\n        }\n\n        List<BinaryHunk> binary() {\n            return binary;\n        }\n\n        List<Hunk> textual() {\n            return textual;\n        }\n    }\n\n    private final String delimiter;\n    private String line = null;\n\n    private GitRawDiffParser(String delimiter) {\n        this.delimiter = delimiter;\n    }\n\n    private List<PatchHeader> parseRawLines(InputStream stream) throws IOException {\n        return parseRawLines(new UnixStreamReader(stream));\n    }\n\n    private List<PatchHeader> parseRawLines(UnixStreamReader reader) throws IOException {\n        var headers = new ArrayList<PatchHeader>();\n\n        line = reader.readLine();\n        while (line != null && line.startsWith(\":\")) {\n            headers.add(PatchHeader.fromRawLine(line));\n            line = reader.readLine();\n        }\n\n        return headers;\n    }\n\n    private Hunks parseSingleFileBinaryHunks(UnixStreamReader reader) throws IOException {\n        var hunks = new ArrayList<BinaryHunk>();\n        while ((line = reader.readLine()) != null &&\n                !line.startsWith(\"diff\") &&\n                !line.equals(delimiter)) {\n            var words = line.split(\" \");\n            var format = words[0];\n            var inflatedSize = Integer.parseInt(words[1]);\n\n            var data = new ArrayList<String>();\n            while ((line = reader.readLine()) != null && !line.equals(\"\")) {\n                data.add(line);\n            }\n\n            if (format.equals(\"literal\")) {\n                hunks.add(BinaryHunk.ofLiteral(inflatedSize, data));\n            } else if (format.equals(\"delta\")) {\n                hunks.add(BinaryHunk.ofDelta(inflatedSize, data));\n            } else {\n                throw new IllegalStateException(\"Unexpected binary diff format: \" + words[0]);\n            }\n        }\n        return Hunks.ofBinary(hunks);\n    }\n\n    private Hunks parseSingleFileTextualHunks(UnixStreamReader reader) throws IOException {\n        var hunks = new ArrayList<Hunk>();\n\n        while (line != null && line.startsWith(\"@@\")) {\n            var words = line.split(\"\\\\s\");\n            if (!words[0].startsWith(\"@@\")) {\n                throw new IllegalStateException(\"Unexpected diff line: \" + line);\n            }\n            var sourceRange = words[1].substring(1); // skip initial '-'\n            var targetRange = words[2].substring(1); // skip initial '+'\n\n            var sourceLines = new ArrayList<String>();\n            var sourceHasNewlineAtEndOfFile = true;\n            var targetLines = new ArrayList<String>();\n            var targetHasNewlineAtEndOfFile = true;\n            var hasSeenLinesWithPlusPrefix = false;\n            while ((line = reader.readLine()) != null &&\n                   !line.startsWith(\"@@\") &&\n                   !line.startsWith(\"diff\") &&\n                   !line.equals(delimiter)) {\n                if (line.equals(\"\\\\ No newline at end of file\")) {\n                    if (!hasSeenLinesWithPlusPrefix) {\n                        sourceHasNewlineAtEndOfFile = false;\n                    } else {\n                        targetHasNewlineAtEndOfFile = false;\n                    }\n                    continue;\n                }\n\n                if (line.startsWith(\"-\")) {\n                    sourceLines.add(line.substring(1)); // skip initial '-'\n                } else if (line.startsWith(\"+\")) {\n                    hasSeenLinesWithPlusPrefix = true;\n                    targetLines.add(line.substring(1)); // skip initial '+'\n                } else {\n                    throw new IllegalStateException(\"Unexpected diff line: \" + line);\n                }\n            }\n            hunks.add(new Hunk(Range.fromString(sourceRange), sourceLines, sourceHasNewlineAtEndOfFile,\n                               Range.fromString(targetRange), targetLines, targetHasNewlineAtEndOfFile));\n        }\n\n        return Hunks.ofTextual(hunks);\n    }\n\n    private Hunks parseSingleFileHunks(UnixStreamReader reader) throws IOException {\n        if (!line.startsWith(\"diff\")) {\n            throw new IllegalStateException(\"Unexpected diff line: \" + line);\n        }\n\n        while ((line = reader.readLine()) != null &&\n                !line.startsWith(\"@@\") &&\n                !line.startsWith(\"GIT binary patch\") &&\n                !line.startsWith(\"diff\") &&\n                !line.equals(delimiter)) {\n            // ignore extended headers, we have the data via the 'raw' lines\n        }\n\n        if (line != null && line.startsWith(\"GIT binary patch\")) {\n            return parseSingleFileBinaryHunks(reader);\n        } else {\n            return parseSingleFileTextualHunks(reader);\n        }\n    }\n\n    private List<Hunks> parseHunks(InputStream stream) throws IOException {\n        return parseHunks(new UnixStreamReader(stream));\n    }\n\n    private List<Hunks> parseHunks(UnixStreamReader reader) throws IOException {\n        var hunks = new ArrayList<Hunks>();\n\n        line = reader.readLine();\n        while (line != null && !line.equals(delimiter)) {\n            hunks.add(parseSingleFileHunks(reader));\n        }\n\n        return hunks;\n    }\n\n    public static List<Patch> parse(InputStream stream) throws IOException {\n        return parse(new UnixStreamReader(stream));\n    }\n\n    public static List<Patch> parse(InputStream stream, String delimiter) throws IOException {\n        return parse(new UnixStreamReader(stream), delimiter);\n    }\n\n    public static List<Patch> parse(UnixStreamReader reader) throws IOException {\n        return parse(reader, null);\n    }\n\n    public static List<Patch> parse(UnixStreamReader reader, String delimiter) throws IOException {\n        var parser = new GitRawDiffParser(delimiter);\n\n        var headers = parser.parseRawLines(reader);\n        var hunks = parser.parseHunks(reader);\n\n        if (headers.size() != hunks.size()) {\n            throw new IOException(\"Num headers (\" + headers.size() + \") differ from num hunks (\" + hunks.size() + \")\");\n        }\n\n        var patches = new ArrayList<Patch>();\n        for (var i = 0; i < headers.size(); i++) {\n            var headerForPatch = headers.get(i);\n            var hunksForPatch = hunks.get(i);\n\n            if (hunksForPatch.areBinary()) {\n                patches.add(new BinaryPatch(headerForPatch.sourcePath(), headerForPatch.sourceFileType(), headerForPatch.sourceHash(),\n                                            headerForPatch.targetPath(), headerForPatch.targetFileType(), headerForPatch.targetHash(),\n                                            headerForPatch.status(), hunksForPatch.binary()));\n            } else {\n                patches.add(new TextualPatch(headerForPatch.sourcePath(), headerForPatch.sourceFileType(), headerForPatch.sourceHash(),\n                                             headerForPatch.targetPath(), headerForPatch.targetFileType(), headerForPatch.targetHash(),\n                                             headerForPatch.status(), hunksForPatch.textual()));\n            }\n        }\n\n        return patches;\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/tools/PatchHeader.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.tools;\n\nimport org.openjdk.skara.vcs.*;\n\nimport java.nio.file.Path;\nimport java.util.Objects;\n\npublic class PatchHeader {\n    private Path sourcePath;\n    private FileType sourceFileType;\n    private Hash sourceHash;\n\n    private Path targetPath;\n    private FileType targetFileType;\n    private Hash targetHash;\n\n    private Status status;\n\n    public PatchHeader(Path sourcePath, FileType sourceFileType, Hash sourceHash,\n                       Path targetPath, FileType targetFileType, Hash targetHash,\n                       Status status) {\n        this.sourcePath = sourcePath;\n        this.sourceFileType = sourceFileType;\n        this.sourceHash = sourceHash;\n        this.targetPath = targetPath;\n        this.targetFileType = targetFileType;\n        this.targetHash = targetHash;\n        this.status = status;\n    }\n\n    public Path sourcePath() {\n        return sourcePath;\n    }\n\n    public FileType sourceFileType() {\n        return sourceFileType;\n    }\n\n    public Hash sourceHash() {\n        return sourceHash;\n    }\n\n    public Path targetPath() {\n        return targetPath;\n    }\n\n    public FileType targetFileType() {\n        return targetFileType;\n    }\n\n    public Hash targetHash() {\n        return targetHash;\n    }\n\n    public Status status() {\n        return status;\n    }\n\n    public static PatchHeader fromRawLine(String line) {\n        if (line == null || line.isEmpty() || line.charAt(0) != ':') {\n            throw new IllegalArgumentException(\"Raw line does not start with colon: \" + line);\n        }\n        var sourceType = FileType.fromOctal(line.substring(1, 7));\n        var targetType = FileType.fromOctal(line.substring(8, 14));\n\n        var sourceHash = new Hash(line.substring(15, 55));\n        var targetHash = new Hash(line.substring(56, 96));\n\n        var rest = line.substring(97);\n        var parts = rest.split(\"\\t\");\n        var status = Status.from(parts[0]);\n\n        Path sourcePath = null;\n        Path targetPath = null;\n        if (status.isModified()) {\n            sourcePath = Path.of(parts[1]);\n            targetPath = sourcePath;\n        } else if (status.isAdded()) {\n            targetPath = Path.of(parts[1]);\n        } else if (status.isDeleted()) {\n            sourcePath = Path.of(parts[1]);\n        } else if (status.isUnmerged()) {\n            sourcePath = Path.of(parts[1]);\n        } else if (status.isFileTypeChanged()) {\n            sourcePath = Path.of(parts[1]);\n            targetPath = sourcePath;\n        } else {\n            // either copied or renamed\n            sourcePath = Path.of(parts[1]);\n            targetPath = Path.of(parts[2]);\n        }\n\n        return new PatchHeader(sourcePath, sourceType, sourceHash, targetPath, targetType, targetHash, status);\n    }\n\n    public String toRawLine() {\n        var sb = new StringBuilder();\n        sb.append(\":\");\n        if (sourceFileType == null) {\n            sb.append(\"000000\");\n        } else {\n            sb.append(sourceFileType.toOctal());\n        }\n        sb.append(\" \");\n        if (targetFileType == null) {\n            sb.append(\"000000\");\n        } else {\n            sb.append(targetFileType.toOctal());\n        }\n        sb.append(\" \");\n        sb.append(status.toString());\n        sb.append(\" \");\n        if (sourcePath != null) {\n            sb.append(sourcePath.toString());\n        }\n        if (targetPath != null) {\n            if (sourcePath != null) {\n                sb.append(\" \");\n            }\n            sb.append(targetPath.toString());\n        }\n        return sb.toString();\n    }\n\n    @Override\n    public String toString() {\n        return toRawLine();\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (!(o instanceof PatchHeader other)) {\n            return false;\n        }\n\n        return Objects.equals(sourcePath, other.sourcePath()) &&\n               Objects.equals(sourceFileType, other.sourceFileType()) &&\n               Objects.equals(sourceHash, other.sourceHash()) &&\n               Objects.equals(targetPath, other.targetPath()) &&\n               Objects.equals(targetFileType, other.targetFileType()) &&\n               Objects.equals(targetHash, other.targetHash()) &&\n               Objects.equals(status, other.status());\n    }\n\n    @Override\n    public int hashCode() {\n        return Objects.hash(sourcePath, sourceFileType, targetPath, targetFileType, status);\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/java/org/openjdk/skara/vcs/tools/UnixStreamReader.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.tools;\n\nimport java.nio.charset.StandardCharsets;\nimport java.io.*;\nimport java.util.Arrays;\n\npublic class UnixStreamReader {\n    private final InputStream stream;\n\n    private byte[] buffer;\n    private String lastLine;\n\n    public UnixStreamReader(InputStream stream) {\n        this.stream = stream;\n        this.buffer = new byte[128];\n        this.lastLine = null;\n    }\n\n    public String readLine() throws IOException {\n        var index = 0;\n        var res = stream.read();\n        while (res != -1) {\n            if (res == (int) '\\n') {\n                lastLine = new String(buffer, 0, index, StandardCharsets.UTF_8);\n                return lastLine;\n            } else {\n                if (index == buffer.length) {\n                    buffer = Arrays.copyOf(buffer, buffer.length * 2);\n                }\n                buffer[index] = (byte) res;\n                index++;\n            }\n\n            res = stream.read();\n        }\n\n        lastLine = null;\n        return lastLine;\n    }\n\n    public byte[] read(int n) throws IOException {\n        var result = new byte[n];\n        read(result);\n        return result;\n    }\n\n    public void read(byte[] b) throws IOException {\n        var read = 0;\n        while (read != b.length) {\n            read += stream.read(b, read, b.length - read);\n        }\n    }\n\n    public String lastLine() {\n        return lastLine;\n    }\n}\n"
  },
  {
    "path": "vcs/src/main/resources/ext.py",
    "content": "# Copyright (c) 2018, 2023, Oracle and/or its affiliates. All rights reserved.\n# DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n#\n# This code is free software; you can redistribute it and/or modify it\n# under the terms of the GNU General Public License version 2 only, as\n# published by the Free Software Foundation.\n#\n# This code is distributed in the hope that it will be useful, but WITHOUT\n# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n# version 2 for more details (a copy is included in the LICENSE file that\n# accompanied this code).\n#\n# You should have received a copy of the GNU General Public License version\n# 2 along with this work; if not, write to the Free Software Foundation,\n# Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n#\n# Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n# or visit www.oracle.com if you need additional information or have any\n# questions.\n\nimport mercurial\nimport mercurial.patch\nimport mercurial.mdiff\nimport mercurial.util\nimport mercurial.hg\nimport mercurial.node\nimport mercurial.copies\nimport difflib\nimport sys\nimport hashlib\n\n# space separated version list\ntestedwith = '4.9.2 5.0.2 5.2.1 5.3.0'\n\ndef mode(fctx):\n    flags = fctx.flags()\n    if flags == b'': return b'100644'\n    if flags == b'x': return b'100755'\n    if flags == b'l': return b'120000'\n\ndef ratio(a, b, threshold):\n    s = difflib.SequenceMatcher(None, a, b)\n    if s.real_quick_ratio() < threshold:\n        return 0\n    if s.quick_ratio() < threshold:\n        return 0\n    ratio = s.ratio()\n    if ratio < threshold:\n        return 0\n    return ratio\n\ndef write(s):\n    if sys.version_info >= (3, 0):\n        sys.stdout.buffer.write(s)\n    else:\n        sys.stdout.write(s)\n\ndef writeln(s):\n    write(s)\n    write(b'\\n')\n\ndef int_to_str(i):\n    return str(i).encode('ascii')\n\ndef _match_exact(root, cwd, files, badfn=None):\n    \"\"\"\n    Wrapper for mercurial.match.exact that ignores some arguments based on the used version\n    \"\"\"\n    if int(mercurial.util.version().split(b\".\")[0]) >= 5:\n        return mercurial.match.exact(files, badfn)\n    else:\n        return mercurial.match.exact(root, cwd, files, badfn)\n\ndef _diff_git_raw(repo, ctx1, ctx2, modified, added, removed, showPatch):\n    nullHash = b'0' * 40\n    removed_copy = set(removed)\n\n    copied = mercurial.copies.pathcopies(ctx1, ctx2)\n\n    for path in added:\n        fctx = ctx2.filectx(path)\n        if fctx.renamed():\n            old_path, _ = fctx.renamed()\n            if old_path in removed:\n                removed_copy.discard(old_path)\n        elif path in copied:\n            old_path = copied[path]\n            if old_path in removed:\n                removed_copy.discard(old_path)\n\n    for path in sorted(modified | added | removed_copy):\n        if path in modified:\n            fctx = ctx2.filectx(path)\n            writeln(b':' + mode(ctx1.filectx(path)) + b' ' + mode(fctx) + b' ' + nullHash + b' ' + nullHash + b' M\\t' + fctx.path())\n        elif path in added:\n            fctx = ctx2.filectx(path)\n            if fctx.renamed():\n                parent = fctx.p1()\n                score = int_to_str(int(ratio(parent.data(), fctx.data(), 0.5) * 100))\n                old_path, _ = fctx.renamed()\n\n                if old_path in removed:\n                    operation = b'R'\n                else:\n                    operation = b'C'\n\n                write(b':' + mode(parent) + b' ' + mode(fctx) + b' ' + nullHash + b' ' + nullHash + b' ')\n                writeln(operation + score + b'\\t' + old_path + b'\\t' + path)\n            elif path in copied:\n                old_path = copied[path]\n                score = b'100'\n\n                if old_path in removed:\n                    operation = b'R'\n                else:\n                    operation = b'C'\n\n                write(b':' + mode(fctx) + b' ' + mode(fctx) + b' ' + nullHash + b' ' + nullHash + b' ')\n                writeln(operation + score + b'\\t' + old_path + b'\\t' + path)\n            else:\n                writeln(b':000000 ' + mode(fctx) + b' ' + nullHash + b' ' + nullHash + b' A\\t' + fctx.path())\n        elif path in removed_copy:\n            fctx = ctx1.filectx(path)\n            writeln(b':' + mode(fctx) + b' 000000 ' + nullHash + b' ' + nullHash + b' D\\t' + path)\n\n    if showPatch:\n        writeln(b'')\n\n        match = _match_exact(repo.root, repo.getcwd(), list(modified) + list(added) + list(removed_copy))\n        opts = mercurial.mdiff.diffopts(git=True, nodates=True, context=0)\n        for d in mercurial.patch.diff(repo, ctx1.node(), ctx2.node(), match=match, opts=opts):\n            write(d)\n\ndef really_differs(repo, p1, p2, ctx, files):\n    # workaround bug in hg (present since forever):\n    # `hg status` can, for merge commits, report a file as modififed between one parent\n    # and the merge even though it isn't. `hg diff` works correctly, so remove any \"modified\"\n    # that has an empty diff against one of its parents\n    differs = set()\n    for path in files:\n        match = _match_exact(repo.root, repo.getcwd(), [path])\n        opts = mercurial.mdiff.diffopts(git=True, nodates=True, context=0, showfunc=True)\n\n        diff1 = mercurial.patch.diff(repo, p1.node(), ctx.node(), match=match, opts=opts)\n        diff2 = mercurial.patch.diff(repo, p2.node(), ctx.node(), match=match, opts=opts)\n        if len(list(diff1)) > 0 and len(list(diff2)) > 0:\n            differs.add(path)\n\n    return differs\n\ncmdtable = {}\nif hasattr(mercurial, 'registrar') and hasattr(mercurial.registrar, 'command'):\n    command = mercurial.registrar.command(cmdtable)\nelif hasattr(mercurial.cmdutil, 'command'):\n    command = mercurial.cmdutil.command(cmdtable)\nelse:\n    def command(name, options, synopsis):\n        def decorator(func):\n            cmdtable[name] = func, list(options), synopsis\n            return func\n        return decorator\n\nif hasattr(mercurial, 'utils') and hasattr(mercurial.utils, 'dateutil'):\n    datestr = mercurial.utils.dateutil.datestr\nelse:\n    datestr = mercurial.util.datestr\n\nif hasattr(mercurial, 'scmutil'):\n    revsingle = mercurial.scmutil.revsingle\n    revrange = mercurial.scmutil.revrange\nelse:\n    revsingle = mercurial.cmdutil.revsingle\n    revrange = mercurial.cmdutil.revrange\n\ndef _status(repo, ctx1, ctx2=None):\n    if ctx2 == None:\n        return tuple(repo.status(ctx1))\n    else:\n        return tuple(repo.status(ctx1, ctx2))\n\n@command(b'diff-git-raw', [(b'', b'patch', False, b''), (b'', b'files', b'', b'')], b'hg diff-git-raw rev1 [rev2]')\ndef diff_git_raw(ui, repo, rev1, rev2=None, *files, **opts):\n    ctx1 = revsingle(repo, rev1)\n\n    if rev2 != None:\n        ctx2 = revsingle(repo, rev2)\n        status = _status(repo, ctx1, ctx2)\n    else:\n        ctx2 = mercurial.context.workingctx(repo)\n        status = _status(repo, ctx1)\n\n    modified, added, removed = [set(l) for l in status[:3]]\n\n    files = opts['files']\n    if files != b'':\n        wanted = set(files.split(b','))\n        modified = modified & wanted\n        added = added & wanted\n        removed = removed & wanted\n\n    _diff_git_raw(repo, ctx1, ctx2, modified, added, removed, opts['patch'])\n\n@command(b'log-git', [(b'', b'reverse', False, b''), (b'l', b'limit', -1, b'')],  b'hg log-git <revisions>')\ndef log_git(ui, repo, revs=None, **opts):\n    if len(repo) == 0:\n        return\n\n    if revs == None:\n        if opts['reverse']:\n            revs = b'0:tip'\n        else:\n            revs = b'tip:0'\n\n    limit = opts['limit']\n    i = 0\n    for r in revrange(repo, [revs]):\n        ctx = repo[r]\n\n        __dump_metadata(ctx)\n        parents = ctx.parents()\n\n        if len(parents) == 1:\n            modified, added, removed = [set(l) for l in _status(repo, parents[0], ctx)[:3]]\n            _diff_git_raw(repo, parents[0], ctx, modified, added, removed, True)\n        else:\n            p1 = parents[0]\n            p2 = parents[1]\n\n            modified_p1, added_p1, removed_p1 = [set(l) for l in _status(repo, p1, ctx)[:3]]\n            modified_p2, added_p2, removed_p2 = [set(l) for l in _status(repo, p2, ctx)[:3]]\n\n            added_both = added_p1 & added_p2\n            modified_both = modified_p1 & modified_p2\n            removed_both = removed_p1 & removed_p2\n\n            combined_modified_p1 = modified_both | (modified_p1 & added_p2)\n            combined_added_p1 = added_both | (added_p1 & modified_p2)\n            combined_modified_p2 = modified_both | (modified_p2 & added_p1)\n            combined_added_p2 = added_both | (added_p2 & modified_p1)\n\n            combined_modified_p1 = really_differs(repo, p1, p2, ctx, combined_modified_p1)\n            combined_added_p1 = really_differs(repo, p1, p2, ctx, combined_added_p1)\n            combined_modified_p2 = really_differs(repo, p1, p2, ctx, combined_modified_p2)\n            combined_added_p2 = really_differs(repo, p1, p2, ctx, combined_added_p2)\n\n            _diff_git_raw(repo, p1, ctx, combined_modified_p1, combined_added_p1, removed_both, True)\n            writeln(b'#@!_-=&')\n            _diff_git_raw(repo, p2, ctx, combined_modified_p2, combined_added_p2, removed_both, True)\n\n        i += 1\n        if i == limit:\n            break\n\ndef __dump_metadata(ctx):\n        writeln(b'#@!_-=&')\n        writeln(ctx.hex())\n        writeln(int_to_str(ctx.rev()))\n        writeln(ctx.branch())\n\n        parents = ctx.parents()\n        writeln(b' '.join([p.hex() for p in parents]))\n        writeln(b' '.join([int_to_str(p.rev()) for p in parents]))\n\n        writeln(ctx.user())\n        date = datestr(ctx.date(), format=b'%Y-%m-%d %H:%M:%S%z')\n        writeln(date)\n\n        description = ctx.description()\n        writeln(int_to_str(len(description)))\n        write(description)\n\ndef __dump(repo, start, end):\n    for rev in range(start, end):\n        ctx = revsingle(repo, rev)\n\n        __dump_metadata(ctx)\n        parents = ctx.parents()\n\n        modified, added, removed = _status(repo, parents[0], ctx)[:3]\n        writeln(int_to_str(len(modified)))\n        writeln(int_to_str(len(added)))\n        writeln(int_to_str(len(removed)))\n\n        for filename in added + modified:\n            fctx = ctx.filectx(filename)\n\n            writeln(filename)\n            writeln(b' '.join(fctx.flags()))\n\n            content = fctx.data()\n            writeln(int_to_str(len(content)))\n            write(content)\n\n        for filename in removed:\n            writeln(filename)\n\ndef pretxnclose(ui, repo, **kwargs):\n    start = revsingle(repo, kwargs['node'])\n    end = revsingle(repo, kwargs['node_last'])\n    __dump(repo, start.rev(), end.rev() + 1)\n\n@command(b'dump', [], b'hg dump')\ndef dump(ui, repo, **opts):\n    __dump(repo, 0, len(repo))\n\n@command(b'metadata', [], b'hg metadata')\ndef metadata(ui, repo, revs, filenames=None, **opts):\n    if filenames != None:\n        fnames = filenames.split(b\"\\t\")\n\n    for r in revrange(repo, [revs]):\n        ctx = repo[r]\n        if filenames == None:\n            __dump_metadata(ctx)\n        else:\n            modified, added, removed = tuple(ctx.status(ctx.p1(), _match_exact(repo.root, repo.getcwd(), fnames)))[:3]\n            if modified or added or removed:\n                __dump_metadata(ctx)\n\n@command(b'ls-tree', [], b'hg ls-tree')\ndef ls_tree(ui, repo, rev, **opts):\n    ctx = revsingle(repo, rev)\n    for filename in ctx.manifest():\n        fctx = ctx.filectx(filename)\n        if b'x' in fctx.flags():\n            write(b'100755 blob ')\n        else:\n            write(b'100644 blob ')\n        data = fctx.data()\n        m = hashlib.sha1()\n        m.update(b\"blob \")\n        m.update(str(len(data)).encode())\n        m.update(b\"\\x00\")\n        m.update(data)\n        write(m.hexdigest().encode())\n        write(b'\\t')\n        writeln(filename)\n\n@command(b'ls-remote', [], b'hg ls-remote PATH')\ndef ls_remote(ui, repo, path, **opts):\n    try:\n        peer = mercurial.hg.peer(ui or repo, opts, ui.expandpath(path))\n    except:\n        from mercurial import utils\n        origsource, remote_path, branch = utils.urlutil.get_clone_path(ui, path)\n        peer = mercurial.hg.peer(repo, opts, remote_path)\n\n    for branch, heads in peer.branchmap().iteritems():\n        for head in heads:\n            write(mercurial.node.hex(head))\n            write(b\"\\t\")\n            writeln(branch)\n"
  },
  {
    "path": "vcs/src/test/java/org/openjdk/skara/vcs/AuthorTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.util.*;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class AuthorTests {\n    @Test\n    void testFromStringWithOnlyUsername() {\n        var s = \"foo\";\n        var a = Author.fromString(s);\n        assertEquals(\"foo\", a.name());\n        assertNull(a.email());\n    }\n\n    @Test\n    void testFromStringWithUsernameAndEmail() {\n        var s = \"foo <foo@bar>\";\n        var a = Author.fromString(s);\n        assertEquals(\"foo\", a.name());\n        assertEquals(\"foo@bar\", a.email());\n    }\n\n    @Test\n    void testFromStringWithUnclosedEmail() {\n        var s = \"foo <foo@bar\";\n        var a = Author.fromString(s);\n        assertEquals(\"foo <foo@bar\", a.name());\n        assertNull(a.email());\n    }\n\n    @Test\n    void testFromStringWithUnopenedEmail() {\n        var s = \"foo foo@bar>\";\n        var a = Author.fromString(s);\n        assertEquals(\"foo foo@bar>\", a.name());\n        assertNull(a.email());\n    }\n\n    @Test\n    void testFromStringWithTrailingWhitespace() {\n        var s = \"foo <foo@bar>   \";\n        var a = Author.fromString(s);\n        assertEquals(\"foo <foo@bar>   \", a.name());\n        assertNull(a.email());\n    }\n\n    @Test\n    void testFromStringWithWhitespaceInUsername() {\n        var s = \"foo  <foo@bar>\";\n        var a = Author.fromString(s);\n        assertEquals(\"foo\", a.name());\n        assertEquals(\"foo@bar\", a.email());\n    }\n}\n"
  },
  {
    "path": "vcs/src/test/java/org/openjdk/skara/vcs/RepositoryTests.java",
    "content": "/*\n * Copyright (c) 2018, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.text.Normalizer;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.openjdk.skara.test.TemporaryDirectory;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.openjdk.skara.test.TestableRepository;\nimport org.openjdk.skara.vcs.git.GitRepository;\nimport org.openjdk.skara.vcs.hg.HgRepository;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.file.*;\nimport java.nio.file.attribute.*;\nimport java.time.ZonedDateTime;\nimport java.util.*;\nimport java.util.stream.Collectors;\n\nimport static java.nio.file.StandardOpenOption.*;\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.junit.jupiter.api.Assumptions.assumeTrue;\nimport static org.junit.jupiter.api.Assumptions.assumeFalse;\n\npublic class RepositoryTests {\n\n    private static boolean hgAvailable = true;\n\n    @BeforeAll\n    static void setup() {\n        GitRepository.ignoreConfiguration();\n        HgRepository.ignoreConfiguration();\n\n        try {\n            var pb = new ProcessBuilder(\"hg\", \"--version\");\n            pb.redirectErrorStream(true);\n            var process = pb.start();\n            process.waitFor();\n            hgAvailable = (process.exitValue() == 0);\n        } catch (Exception e) {\n            hgAvailable = false;\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testExistsOnMissingDirectory(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        var d = Paths.get(\"/\", \"this\", \"path\", \"does\", \"not\", \"exist\");\n        var r = Repository.get(d);\n        assertTrue(r.isEmpty());\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testExistsOnEmptyDirectory(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = Repository.get(dir.path());\n            assertTrue(r.isEmpty());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testExistsOnInitializedRepository(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.exists());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testExistsOnSubdir(VCS vcs) throws IOException {\n        assumeTrue(vcs == VCS.GIT);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.exists());\n\n            var subdir = Paths.get(dir.toString(), \"test\");\n            Files.createDirectories(subdir);\n            var r2 = Repository.get(subdir);\n            assertTrue(r2.get().exists());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testRootOnTopLevel(VCS vcs) throws IOException {\n        assumeTrue(vcs == VCS.GIT);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertEquals(dir.toString(), r.root().toString());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testRootOnSubdirectory(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertEquals(dir.toString(), r.root().toString());\n\n            var subdir = Paths.get(dir.toString(), \"sub\");\n            Files.createDirectories(subdir);\n\n            var r2 = Repository.get(subdir);\n            assertEquals(dir.toString(), r2.get().root().toString());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testResolveOnEmptyRepository(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.resolve(\"HEAD\").isEmpty());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testResolveWithHEAD(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            var head = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n            assertEquals(head, r.head());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testConfig(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            if (vcs == VCS.GIT) {\n                var config = dir.path().resolve(\".git\").resolve(\"config\");\n                Files.write(config, List.of(\"[user]\", \"name = duke\"), WRITE, APPEND);\n                assertEquals(List.of(\"duke\"), r.config(\"user.name\"));\n            } else if (vcs == VCS.HG) {\n                var config = dir.path().resolve(\".hg\").resolve(\"hgrc\");\n                Files.write(config, List.of(\"[ui]\", \"username = duke\"), WRITE, CREATE);\n                assertEquals(List.of(\"duke\"), r.config(\"ui.username\"));\n            }\n\n            assertEquals(\"duke\", r.username().get());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testCurrentBranchOnEmptyRepository(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertEquals(r.defaultBranch(), r.currentBranch().get());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testCheckout(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n            r.add(readme);\n\n            var head1 = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n            assertEquals(head1, r.head());\n\n            Files.write(readme, List.of(\"Another line\"), WRITE, APPEND);\n            r.add(readme);\n\n            var head2 = r.commit(\"Add one more line\", \"duke\", \"duke@openjdk.org\");\n            assertEquals(head2, r.head());\n\n            r.checkout(head1, false);\n            assertEquals(head1, r.head());\n\n            r.checkout(head2, false);\n            assertEquals(head2, r.head());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testLines(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n            r.add(readme);\n\n            var head1 = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n            assertEquals(List.of(\"Hello, readme!\"),\n                         r.lines(readme, head1).orElseThrow());\n\n            Files.write(readme, List.of(\"Another line\"), WRITE, APPEND);\n            r.add(readme);\n\n            var head2 = r.commit(\"Add one more line\", \"duke\", \"duke@openjdk.org\");\n            assertEquals(List.of(\"Hello, readme!\", \"Another line\"),\n                         r.lines(readme, head2).orElseThrow());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testLinesInSubdir(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            TestableRepository.init(dir.path(), vcs);\n\n            var subdir = dir.path().resolve(\"sub\");\n            Files.createDirectories(subdir);\n            var r = Repository.get(subdir).get();\n\n            var readme = subdir.getParent().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n            r.add(readme);\n\n            var head = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n            assertEquals(List.of(\"Hello, readme!\"),\n                         r.lines(readme, head).orElseThrow());\n\n            var example = subdir.resolve(\"EXAMPLE\");\n            Files.write(example, List.of(\"An example\"));\n            r.add(example);\n\n            var head2 = r.commit(\"Add EXAMPLE\", \"duke\", \"duke@openjdk.org\");\n            assertEquals(List.of(\"An example\"),\n                         r.lines(example, head2).orElseThrow());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testCommitListingOnEmptyRepo(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.commits().asList().isEmpty());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testCommitListingWithSingleCommit(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n\n            var committerName = vcs == VCS.GIT ? \"bot\" : \"duke\";\n            var committerEmail = vcs == VCS.GIT ? \"bot@openjdk.org\" : \"duke@openjdk.org\";\n            var hash = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\", committerName, committerEmail);\n\n            var commits = r.commits().asList();\n            assertEquals(1, commits.size());\n\n            var commit = commits.get(0);\n            assertEquals(\"duke\", commit.author().name());\n            assertEquals(\"duke@openjdk.org\", commit.author().email());\n            assertEquals(committerName, commit.committer().name());\n            assertEquals(committerEmail, commit.committer().email());\n\n            assertEquals(List.of(\"Add README\"), commit.message());\n\n            assertEquals(1, commit.numParents());\n            assertEquals(1, commit.parents().size());\n\n            var parent = commit.parents().get(0);\n            assertEquals(Hash.zero(), parent);\n\n            assertTrue(commit.isInitialCommit());\n            assertFalse(commit.isMerge());\n            assertEquals(hash, commit.hash());\n\n            var diffs = commit.parentDiffs();\n            assertEquals(1, diffs.size());\n\n            var diff = diffs.get(0);\n            assertEquals(Hash.zero(), diff.from());\n            assertEquals(hash, diff.to());\n\n            var stats = diff.totalStats();\n            assertEquals(0, stats.removed());\n            assertEquals(0, stats.modified());\n            assertEquals(1, stats.added());\n\n            var patches = diff.patches();\n            assertEquals(1, patches.size());\n\n            var patch = patches.get(0).asTextualPatch();\n            assertTrue(patch.status().isAdded());\n\n            assertTrue(patch.source().path().isEmpty());\n            assertTrue(patch.source().type().isEmpty());\n\n            assertEquals(Path.of(\"README\"), patch.target().path().get());\n            assertTrue(patch.target().type().get().isRegularNonExecutable());\n\n            var hunks = patch.hunks();\n            assertEquals(1, hunks.size());\n\n            var hunk = hunks.get(0);\n            assertEquals(new Range(0, 0), hunk.source().range());\n            assertEquals(new Range(1, 1), hunk.target().range());\n\n            assertLinesEquals(List.of(), hunk.source().lines());\n            assertLinesEquals(List.of(\"Hello, readme!\"), hunk.target().lines());\n        }\n    }\n\n    static String stripTrailingCR(String line) {\n        return line.endsWith(\"\\r\") ? line.substring(0, line.length() - 1) : line;\n    }\n\n    static void assertLinesEquals(List<String> expected, List<String> actual) {\n        assertEquals(expected, actual.stream().map(RepositoryTests::stripTrailingCR).collect(Collectors.toList()));\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testCommitListingWithMultipleCommits(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            var hash1 = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"Another line\"), WRITE, APPEND);\n            r.add(readme);\n            var hash2 = r.commit(\"Modify README\", \"duke\", \"duke@openjdk.org\");\n\n            var commits = r.commits().asList();\n            assertEquals(2, commits.size());\n\n            var commit = commits.get(0);\n            assertEquals(\"duke\", commit.author().name());\n            assertEquals(\"duke@openjdk.org\", commit.author().email());\n\n            assertEquals(List.of(\"Modify README\"), commit.message());\n\n            assertEquals(1, commit.numParents());\n            assertEquals(1, commit.parents().size());\n\n            var parent = commit.parents().get(0);\n            assertEquals(hash1, parent);\n\n            assertFalse(commit.isInitialCommit());\n            assertFalse(commit.isMerge());\n            assertEquals(hash2, commit.hash());\n\n            var diffs = commit.parentDiffs();\n            assertEquals(1, diffs.size());\n\n            var diff = diffs.get(0);\n            assertEquals(hash1, diff.from());\n            assertEquals(hash2, diff.to());\n\n            var stats = diff.totalStats();\n            assertEquals(0, stats.removed());\n            assertEquals(0, stats.modified());\n            assertEquals(1, stats.added());\n\n            var patches = diff.patches();\n            assertEquals(1, patches.size());\n\n            var patch = patches.get(0).asTextualPatch();\n            assertTrue(patch.status().isModified());\n            assertEquals(Path.of(\"README\"), patch.source().path().get());\n            assertTrue(patch.source().type().get().isRegularNonExecutable());\n            assertEquals(Path.of(\"README\"), patch.target().path().get());\n            assertTrue(patch.target().type().get().isRegularNonExecutable());\n\n            var hunks = patch.hunks();\n            assertEquals(1, hunks.size());\n\n            var hunk = hunks.get(0);\n            assertEquals(new Range(2, 0), hunk.source().range());\n            assertEquals(new Range(2, 1), hunk.target().range());\n\n            assertLinesEquals(List.of(), hunk.source().lines());\n            assertLinesEquals(List.of(\"Another line\"), hunk.target().lines());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testSquashDeletes(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var file1 = dir.path().resolve(\"file1.txt\");\n            Files.write(file1, List.of(\"Hello, file 1!\"));\n            var file2 = dir.path().resolve(\"file2.txt\");\n            Files.write(file2, List.of(\"Hello, file 2!\"));\n            var file3 = dir.path().resolve(\"file3.txt\");\n            Files.write(file3, List.of(\"Hello, file 3!\"));\n\n            r.add(file1, file2, file3);\n            var hash1 = r.commit(\"Add files\", \"duke\", \"duke@openjdk.org\");\n\n            Files.delete(file2);\n            r.remove(file2);\n            var hash2 = r.commit(\"Remove file 2\", \"duke\", \"duke@openjdk.org\");\n\n            Files.delete(file3);\n            r.remove(file3);\n            var hash3 = r.commit(\"Remove file 3\", \"duke\", \"duke@openjdk.org\");\n\n            var refspec = vcs == VCS.GIT ? r.head().hex() : r.head().hex() + \":0\";\n            assertEquals(3, r.commits(refspec).asList().size());\n\n            r.checkout(hash1, false);\n            r.squash(hash3);\n            r.commit(\"Squashed remove of file 2 and 3\", \"duke\", \"duke@openjdk.org\");\n\n            refspec = vcs == VCS.GIT ? r.head().hex() : r.head().hex() + \":0\";\n            var commits = r.commits(refspec).asList();\n            assertEquals(2, commits.size());\n\n            assertEquals(hash1, commits.get(1).hash());\n\n            var head = commits.get(0);\n            assertNotEquals(hash2, head);\n            assertNotEquals(hash3, head);\n\n            assertEquals(hash1, head.parents().get(0));\n            assertFalse(head.isInitialCommit());\n            assertFalse(head.isMerge());\n\n            var diffs = head.parentDiffs();\n            assertEquals(1, diffs.size());\n\n            var diff = diffs.get(0);\n            assertEquals(hash1, diff.from());\n            assertEquals(head.hash(), diff.to());\n\n            var stats = diff.totalStats();\n            assertEquals(2, stats.removed());\n            assertEquals(0, stats.modified());\n            assertEquals(0, stats.added());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testSquash(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            var hash1 = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"Another line\"), WRITE, APPEND);\n            r.add(readme);\n            var hash2 = r.commit(\"Modify README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"A final line\"), WRITE, APPEND);\n            r.add(readme);\n            var hash3 = r.commit(\"Modify README again\", \"duke\", \"duke@openjdk.org\");\n\n            var refspec = vcs == VCS.GIT ? r.head().hex() : r.head().hex() + \":0\";\n            assertEquals(3, r.commits(refspec).asList().size());\n\n            r.checkout(hash1, false);\n            r.squash(hash3);\n            r.commit(\"Squashed commits 2 and 3\", \"duke\", \"duke@openjdk.org\");\n\n            refspec = vcs == VCS.GIT ? r.head().hex() : r.head().hex() + \":0\";\n            var commits = r.commits(refspec).asList();\n            assertEquals(2, commits.size());\n\n            assertEquals(hash1, commits.get(1).hash());\n\n            var head = commits.get(0);\n            assertNotEquals(hash2, head);\n            assertNotEquals(hash3, head);\n\n            assertEquals(hash1, head.parents().get(0));\n            assertFalse(head.isInitialCommit());\n            assertFalse(head.isMerge());\n\n            var diffs = head.parentDiffs();\n            assertEquals(1, diffs.size());\n\n            var diff = diffs.get(0);\n            assertEquals(hash1, diff.from());\n            assertEquals(head.hash(), diff.to());\n\n            var stats = diff.totalStats();\n            assertEquals(0, stats.removed());\n            assertEquals(0, stats.modified());\n            assertEquals(2, stats.added());\n\n            var patches = diff.patches();\n            assertEquals(1, patches.size());\n\n            var patch = patches.get(0).asTextualPatch();\n            assertTrue(patch.status().isModified());\n            assertEquals(Path.of(\"README\"), patch.source().path().get());\n            assertTrue(patch.source().type().get().isRegularNonExecutable());\n            assertEquals(Path.of(\"README\"), patch.target().path().get());\n            assertTrue(patch.target().type().get().isRegularNonExecutable());\n\n            var hunks = patch.hunks();\n            assertEquals(1, hunks.size());\n\n            var hunk = hunks.get(0);\n            assertEquals(new Range(2, 0), hunk.source().range());\n            assertEquals(new Range(2, 2), hunk.target().range());\n\n            assertLinesEquals(List.of(), hunk.source().lines());\n            assertLinesEquals(List.of(\"Another line\", \"A final line\"), hunk.target().lines());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testMergeBase(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            var hash1 = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"Another line\"), WRITE, APPEND);\n            r.add(readme);\n            var hash2 = r.commit(\"Modify README\", \"duke\", \"duke@openjdk.org\");\n\n            r.checkout(hash1, false);\n            Files.write(readme, List.of(\"A conflicting line\"), WRITE, APPEND);\n            r.add(readme);\n            var hash3 = r.commit(\"Branching README modification\", \"duke\", \"duke@openjdk.org\");\n\n            assertEquals(hash1, r.mergeBase(hash2, hash3));\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testIsAncestor(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            var hash1 = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"Another line\"), WRITE, APPEND);\n            r.add(readme);\n            var hash2 = r.commit(\"Modify README\", \"duke\", \"duke@openjdk.org\");\n\n            assertTrue(r.isAncestor(hash1, hash2));\n\n            r.checkout(hash1, false);\n            Files.write(readme, List.of(\"A conflicting line\"), WRITE, APPEND);\n            r.add(readme);\n            var hash3 = r.commit(\"Branching README modification\", \"duke\", \"duke@openjdk.org\");\n\n            assertTrue(r.isAncestor(hash1, hash3));\n            assertFalse(r.isAncestor(hash2, hash3));\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testRebase(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            var hash1 = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"Another line\"), WRITE, APPEND);\n            r.add(readme);\n            var hash2 = r.commit(\"Modify README\", \"duke\", \"duke@openjdk.org\");\n\n            r.checkout(hash1, false);\n\n            var contributing = dir.path().resolve(\"CONTRIBUTING\");\n            Files.write(contributing, List.of(\"Keep the patches coming\"));\n            r.add(contributing);\n            var hash3 = r.commit(\"Add independent change\", \"duke\", \"duke@openjdk.org\");\n\n            var committerName = vcs == VCS.GIT ? \"bot\" : \"duke\";\n            var committerEmail = vcs == VCS.GIT ? \"bot@openjdk.org\" : \"duke@openjdk.org\";\n            r.rebase(hash2, committerName, committerEmail);\n\n            var refspec = vcs == VCS.GIT ? r.head().hex() : r.head().hex() + \":0\";\n            var commits = r.commits(refspec).asList();\n            assertEquals(3, commits.size());\n            assertEquals(hash2, commits.get(1).hash());\n            assertEquals(hash1, commits.get(2).hash());\n\n            assertEquals(\"duke\", commits.get(0).author().name());\n            assertEquals(\"duke@openjdk.org\", commits.get(0).author().email());\n            assertEquals(committerName, commits.get(0).committer().name());\n            assertEquals(committerEmail, commits.get(0).committer().email());\n\n            assertEquals(\"duke\", commits.get(1).author().name());\n            assertEquals(\"duke@openjdk.org\", commits.get(1).author().email());\n            assertEquals(\"duke\", commits.get(1).committer().name());\n            assertEquals(\"duke@openjdk.org\", commits.get(1).committer().email());\n\n            assertEquals(\"duke\", commits.get(2).author().name());\n            assertEquals(\"duke@openjdk.org\", commits.get(2).author().email());\n            assertEquals(\"duke\", commits.get(2).committer().name());\n            assertEquals(\"duke@openjdk.org\", commits.get(2).committer().email());\n\n            var head = commits.get(0);\n            assertEquals(hash2, head.parents().get(0));\n            assertEquals(List.of(\"Add independent change\"), head.message());\n\n            var diffs = head.parentDiffs();\n            assertEquals(1, diffs.size());\n            var diff = diffs.get(0);\n\n            var stats = diff.totalStats();\n            assertEquals(0, stats.removed());\n            assertEquals(0, stats.modified());\n            assertEquals(1, stats.added());\n\n            var patches = diff.patches();\n            assertEquals(1, patches.size());\n            var patch = patches.get(0).asTextualPatch();\n            assertEquals(Path.of(\"CONTRIBUTING\"), patch.target().path().get());\n\n            var hunks = patch.hunks();\n            assertEquals(1, hunks.size());\n            var hunk = hunks.get(0);\n            assertLinesEquals(List.of(\"Keep the patches coming\"), hunk.target().lines());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testInitializedRepositoryIsEmpty(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isEmpty());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testRepositoryWithCommitIsNonEmpty(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            assertFalse(r.isEmpty());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testEmptyRepositoryIsHealthy(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isHealthy());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testNonEmptyRepositoryIsHealthy(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            assertTrue(r.isHealthy());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testNonCheckedOutRepositoryIsHealthy(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir1 = new TemporaryDirectory();\n             var dir2 = new TemporaryDirectory()) {\n            var r1 = TestableRepository.init(dir1.path(), vcs);\n\n            var readme = dir1.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r1.add(readme);\n            var hash = r1.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n            r1.tag(hash, \"tag\", \"tagging\", \"duke\", \"duke@openjdk.org\");\n\n            var r2 = TestableRepository.init(dir2.path(), vcs);\n            r2.fetch(r1.root().toUri(), r1.defaultBranch().name()).orElseThrow();\n\n            assertTrue(r2.isHealthy());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testBranchesOnEmptyRepository(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertEquals(List.of(), r.branches());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testBranchesOnNonEmptyRepository(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            assertEquals(List.of(r.defaultBranch()), r.branches());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testTagsOnEmptyRepository(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            var expected = vcs == VCS.GIT ? List.of() : List.of(new Tag(\"tip\"));\n            assertEquals(expected, r.tags());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testTagsOnNonEmptyRepository(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            var expected = vcs == VCS.GIT ? List.of() : List.of(new Tag(\"tip\"));\n            assertEquals(expected, r.tags());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testFetchAndPush(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var upstream = TestableRepository.init(dir.path(), vcs);\n\n            if (vcs == VCS.GIT) {\n                Files.write(upstream.root().resolve(\".git\").resolve(\"config\"),\n                            List.of(\"[receive]\", \"denyCurrentBranch=ignore\"),\n                            WRITE, APPEND);\n            }\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            upstream.add(readme);\n            upstream.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            try (var dir2 = new TemporaryDirectory()) {\n                var downstream = TestableRepository.init(dir2.path(), vcs);\n\n                 // note: forcing unix path separators for URI\n                var upstreamURI = URI.create(\"file:///\" + dir.toString().replace('\\\\', '/'));\n\n                var fetchHead = downstream.fetch(upstreamURI, downstream.defaultBranch().name()).orElseThrow();\n                downstream.checkout(fetchHead, false);\n\n                var downstreamReadme = dir2.path().resolve(\"README\");\n                Files.write(downstreamReadme, List.of(\"Downstream change\"), WRITE, APPEND);\n\n                downstream.add(downstreamReadme);\n                var head = downstream.commit(\"Modify README\", \"duke\", \"duke@openjdk.org\");\n\n                downstream.push(head, upstreamURI, downstream.defaultBranch().name());\n            }\n\n            upstream.checkout(upstream.resolve(upstream.defaultBranch().name()).get(), false);\n\n            var commits = upstream.commits().asList();\n            assertEquals(2, commits.size());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testFetchUpdatedTag(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var upstream = TestableRepository.init(dir.path(), vcs);\n\n            if (vcs == VCS.GIT) {\n                Files.write(upstream.root().resolve(\".git\").resolve(\"config\"),\n                        List.of(\"[receive]\", \"denyCurrentBranch=ignore\"),\n                        WRITE, APPEND);\n            }\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            upstream.add(readme);\n            var firstHash = upstream.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            var firstTag = upstream.tag(firstHash, \"my-tag\", \"First tag message\", \"duke\", \"duke@openjdk.org\");\n\n            try (var dir2 = new TemporaryDirectory()) {\n                var downstream = TestableRepository.init(dir2.path(), vcs);\n\n                // note: forcing unix path separators for URI\n                var upstreamURI = URI.create(\"file:///\" + dir.toString().replace('\\\\', '/'));\n\n                downstream.fetch(upstreamURI, downstream.defaultBranch().name()).orElseThrow();\n                var tagHash = downstream.resolve(firstTag).orElseThrow();\n                downstream.checkout(tagHash, false);\n\n                Files.write(readme, List.of(\"Readme change\"), WRITE, APPEND);\n                upstream.add(readme);\n                var secondHash = upstream.commit(\"Modify README\", \"duke\", \"duke@openjdk.org\");\n                var secondTag = upstream.tag(secondHash, \"my-tag\", \"Second tag message\",\"duke\",\n                        \"duke@openjdk.org\", null, true);\n\n                downstream.fetch(upstreamURI, downstream.defaultBranch().name(), true, true).orElseThrow();\n                tagHash = downstream.resolve(secondTag).orElseThrow();\n                downstream.checkout(tagHash, false);\n                assertEquals(secondHash, tagHash, \"Tag not updated to second hash\");\n            }\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testClean(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            r.clean();\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            var hash1 = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            r.clean();\n\n            assertEquals(hash1, r.head());\n\n            Files.write(readme, List.of(\"A random change\"), WRITE, APPEND);\n\n            r.clean();\n\n            assertEquals(List.of(\"Hello, readme!\"), Files.readAllLines(readme));\n\n            var untracked = dir.path().resolve(\"UNTRACKED\");\n            Files.write(untracked, List.of(\"Random text\"));\n\n            r.clean();\n\n            assertFalse(Files.exists(untracked));\n\n            // Mercurial cannot currently deal with this situation\n            if (vcs != VCS.HG) {\n                var subRepo = TestableRepository.init(dir.path().resolve(\"submodule\"), vcs);\n                var subRepoFile = subRepo.root().resolve(\"file.txt\");\n                Files.write(subRepoFile, List.of(\"Looks like a file in a submodule\"));\n\n                r.clean();\n\n                assertFalse(Files.exists(subRepoFile));\n            }\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testCleanIgnored(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            r.clean();\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n            Files.write(dir.path().resolve(\".gitignore\"), List.of(\"*.txt\"));\n            Files.write(dir.path().resolve(\".hgignore\"), List.of(\".*txt\"));\n\n            r.add(readme);\n            var hash1 = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            var ignored = dir.path().resolve(\"ignored.txt\");\n            Files.write(ignored, List.of(\"Random text\"));\n\n            r.clean();\n\n            assertFalse(Files.exists(ignored));\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testDiffBetweenCommits(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            var first = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"One more line\"), WRITE, APPEND);\n            r.add(readme);\n            var second = r.commit(\"Add one more line\", \"duke\", \"duke@openjdk.org\");\n\n            var diff = r.diff(first, second);\n            assertEquals(first, diff.from());\n            assertEquals(second, diff.to());\n\n            var patches = diff.patches();\n            assertEquals(1, patches.size());\n\n            var patch = patches.get(0).asTextualPatch();\n            assertEquals(Path.of(\"README\"), patch.source().path().get());\n            assertEquals(Path.of(\"README\"), patch.target().path().get());\n            assertTrue(patch.source().type().get().isRegularNonExecutable());\n            assertTrue(patch.target().type().get().isRegularNonExecutable());\n            assertTrue(patch.status().isModified());\n\n            var hunks = patch.hunks();\n            assertEquals(1, hunks.size());\n\n            var hunk = hunks.get(0);\n            assertEquals(2, hunk.source().range().start());\n            assertEquals(0, hunk.source().range().count());\n            assertEquals(0, hunk.source().lines().size());\n\n            assertEquals(2, hunk.target().range().start());\n            assertEquals(1, hunk.target().range().count());\n            assertLinesEquals(List.of(\"One more line\"), hunk.target().lines());\n\n            var stats = hunk.stats();\n            assertEquals(1, stats.added());\n            assertEquals(0, stats.removed());\n            assertEquals(0, stats.modified());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testDiffBetweenCommitsWithMultiplePatches(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            var building = dir.path().resolve(\"BUILDING\");\n            Files.write(building, List.of(\"make\"));\n\n            r.add(readme);\n            r.add(building);\n            var first = r.commit(\"Add README and BUILDING\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"Hello, Skara!\"), WRITE, TRUNCATE_EXISTING);\n            Files.write(building, List.of(\"make images\"), WRITE, TRUNCATE_EXISTING);\n            r.add(readme);\n            r.add(building);\n            var second = r.commit(\"Modify README and BUILDING\", \"duke\", \"duke@openjdk.org\");\n\n            var diff = r.diff(first, second);\n            assertEquals(first, diff.from());\n            assertEquals(second, diff.to());\n\n            var patches = diff.patches();\n            assertEquals(2, patches.size());\n\n            var patch1 = patches.get(0).asTextualPatch();\n            assertEquals(Path.of(\"BUILDING\"), patch1.source().path().get());\n            assertEquals(Path.of(\"BUILDING\"), patch1.target().path().get());\n            assertTrue(patch1.source().type().get().isRegularNonExecutable());\n            assertTrue(patch1.target().type().get().isRegularNonExecutable());\n            assertTrue(patch1.status().isModified());\n\n            var hunks1 = patch1.hunks();\n            assertEquals(1, hunks1.size());\n\n            var hunk1 = hunks1.get(0);\n            assertEquals(1, hunk1.source().range().start());\n            assertEquals(1, hunk1.source().range().count());\n            assertLinesEquals(List.of(\"make\"), hunk1.source().lines());\n\n            assertEquals(1, hunk1.target().range().start());\n            assertEquals(1, hunk1.target().range().count());\n            assertLinesEquals(List.of(\"make images\"), hunk1.target().lines());\n\n            var patch2 = patches.get(1).asTextualPatch();\n            assertEquals(Path.of(\"README\"), patch2.source().path().get());\n            assertEquals(Path.of(\"README\"), patch2.target().path().get());\n            assertTrue(patch2.source().type().get().isRegularNonExecutable());\n            assertTrue(patch2.target().type().get().isRegularNonExecutable());\n            assertTrue(patch2.status().isModified());\n\n            var hunks2 = patch2.hunks();\n            assertEquals(1, hunks2.size());\n\n            var hunk2 = hunks2.get(0);\n            assertEquals(1, hunk2.source().range().start());\n            assertEquals(1, hunk2.source().range().count());\n            assertLinesEquals(List.of(\"Hello, readme!\"), hunk2.source().lines());\n\n            assertEquals(1, hunk2.target().range().start());\n            assertEquals(1, hunk2.target().range().count());\n            assertLinesEquals(List.of(\"Hello, Skara!\"), hunk2.target().lines());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testDiffBetweenCommitsWithMultipleHunks(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var abc = dir.path().resolve(\"abc.txt\");\n            Files.write(abc, List.of(\"A\", \"B\", \"C\"));\n\n            r.add(abc);\n            var first = r.commit(\"Added ABC\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(abc, List.of(\"1\", \"2\", \"B\", \"3\"), WRITE, TRUNCATE_EXISTING);\n            r.add(abc);\n            var second = r.commit(\"Modify A and C\", \"duke\", \"duke@openjdk.org\");\n\n            var diff = r.diff(first, second);\n            assertEquals(first, diff.from());\n            assertEquals(second, diff.to());\n\n            var patches = diff.patches();\n            assertEquals(1, patches.size());\n\n            var patch = patches.get(0).asTextualPatch();\n            assertEquals(Path.of(\"abc.txt\"), patch.source().path().get());\n            assertEquals(Path.of(\"abc.txt\"), patch.target().path().get());\n            assertTrue(patch.source().type().get().isRegularNonExecutable());\n            assertTrue(patch.target().type().get().isRegularNonExecutable());\n            assertTrue(patch.status().isModified());\n\n            var hunks = patch.hunks();\n            assertEquals(2, hunks.size());\n\n            var hunk1 = hunks.get(0);\n            assertEquals(1, hunk1.source().range().start());\n            assertEquals(1, hunk1.source().range().count());\n            assertLinesEquals(List.of(\"A\"), hunk1.source().lines());\n\n            assertEquals(1, hunk1.target().range().start());\n            assertEquals(2, hunk1.target().range().count());\n            assertLinesEquals(List.of(\"1\", \"2\"), hunk1.target().lines());\n\n            var stats1 = hunk1.stats();\n            assertEquals(1, stats1.added());\n            assertEquals(0, stats1.removed());\n            assertEquals(1, stats1.modified());\n\n            var hunk2 = hunks.get(1);\n            assertEquals(3, hunk2.source().range().start());\n            assertEquals(1, hunk2.source().range().count());\n            assertLinesEquals(List.of(\"C\"), hunk2.source().lines());\n\n            assertEquals(4, hunk2.target().range().start());\n            assertEquals(1, hunk2.target().range().count());\n            assertLinesEquals(List.of(\"3\"), hunk2.target().lines());\n\n            var stats2 = hunk2.stats();\n            assertEquals(0, stats2.added());\n            assertEquals(0, stats2.removed());\n            assertEquals(1, stats2.modified());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testDiffWithRemoval(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n\n            r.add(readme);\n            var first = r.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.delete(readme);\n            r.remove(readme);\n            var second = r.commit(\"Removed README\", \"duke\", \"duke@openjdk.org\");\n\n            var diff = r.diff(first, second);\n            assertEquals(first, diff.from());\n            assertEquals(second, diff.to());\n\n            var patches = diff.patches();\n            assertEquals(1, patches.size());\n\n            var patch = patches.get(0).asTextualPatch();\n            assertEquals(Path.of(\"README\"), patch.source().path().get());\n            assertTrue(patch.target().path().isEmpty());\n            assertTrue(patch.source().type().get().isRegularNonExecutable());\n            assertTrue(patch.target().type().isEmpty());\n            assertTrue(patch.status().isDeleted());\n\n            var hunks = patch.hunks();\n            assertEquals(1, hunks.size());\n\n            var hunk = hunks.get(0);\n            assertEquals(1, hunk.source().range().start());\n            assertEquals(1, hunk.source().range().count());\n            assertLinesEquals(List.of(\"Hello, world!\"), hunk.source().lines());\n\n            assertEquals(0, hunk.target().range().start());\n            assertEquals(0, hunk.target().range().count());\n            assertLinesEquals(List.of(), hunk.target().lines());\n\n            var stats = hunk.stats();\n            assertEquals(0, stats.added());\n            assertEquals(1, stats.removed());\n            assertEquals(0, stats.modified());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testDiffWithAddition(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n\n            r.add(readme);\n            var first = r.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n\n            var building = dir.path().resolve(\"BUILDING\");\n            Files.write(building, List.of(\"make\"));\n            r.add(building);\n            var second = r.commit(\"Added BUILDING\", \"duke\", \"duke@openjdk.org\");\n\n            var diff = r.diff(first, second);\n            assertEquals(first, diff.from());\n            assertEquals(second, diff.to());\n\n            var patches = diff.patches();\n            assertEquals(1, patches.size());\n\n            var patch = patches.get(0).asTextualPatch();\n            assertTrue(patch.source().path().isEmpty());\n            assertEquals(Path.of(\"BUILDING\"), patch.target().path().get());\n            assertTrue(patch.source().type().isEmpty());\n            assertTrue(patch.target().type().get().isRegularNonExecutable());\n            assertTrue(patch.status().isAdded());\n\n            var hunks = patch.hunks();\n            assertEquals(1, hunks.size());\n\n            var hunk = hunks.get(0);\n            assertEquals(0, hunk.source().range().start());\n            assertEquals(0, hunk.source().range().count());\n            assertLinesEquals(List.of(), hunk.source().lines());\n\n            assertEquals(1, hunk.target().range().start());\n            assertEquals(1, hunk.target().range().count());\n            assertLinesEquals(List.of(\"make\"), hunk.target().lines());\n\n            var stats = hunk.stats();\n            assertEquals(1, stats.added());\n            assertEquals(0, stats.removed());\n            assertEquals(0, stats.modified());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testDiffWithWorkingDir(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n\n            r.add(readme);\n            var first = r.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"One more line\"), WRITE, APPEND);\n            var diff = r.diff(first);\n\n            assertEquals(first, diff.from());\n            assertNull(diff.to());\n\n            var patches = diff.patches();\n            assertEquals(1, patches.size());\n\n            var patch = patches.get(0).asTextualPatch();\n            assertEquals(Path.of(\"README\"), patch.source().path().get());\n            assertEquals(Path.of(\"README\"), patch.target().path().get());\n            assertTrue(patch.source().type().get().isRegularNonExecutable());\n            assertTrue(patch.target().type().get().isRegularNonExecutable());\n            assertTrue(patch.status().isModified());\n\n            var hunks = patch.hunks();\n            assertEquals(1, hunks.size());\n\n            var hunk = hunks.get(0);\n            assertEquals(2, hunk.source().range().start());\n            assertEquals(0, hunk.source().range().count());\n            assertLinesEquals(List.of(), hunk.source().lines());\n\n            assertEquals(2, hunk.target().range().start());\n            assertEquals(1, hunk.target().range().count());\n            assertLinesEquals(List.of(\"One more line\"), hunk.target().lines());\n\n            var stats = hunk.stats();\n            assertEquals(1, stats.added());\n            assertEquals(0, stats.removed());\n            assertEquals(0, stats.modified());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testCommitMetadata(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n            r.add(readme);\n            var first = r.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"One more line\"), WRITE, APPEND);\n            r.add(readme);\n            var second = r.commit(\"Modified README\", \"duke\", \"duke@openjdk.org\");\n\n            var metadata = r.commitMetadata();\n            assertEquals(2, metadata.size());\n\n            assertEquals(second, metadata.get(0).hash());\n            assertEquals(List.of(\"Modified README\"), metadata.get(0).message());\n\n            assertEquals(first, metadata.get(1).hash());\n            assertEquals(List.of(\"Added README\"), metadata.get(1).message());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testCommitMetadataWithFiles(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme1 = dir.path().resolve(\"README_1\");\n            Files.write(readme1, List.of(\"1\"));\n            r.add(readme1);\n            var first = r.commit(\"Added README_1\", \"duke\", \"duke@openjdk.org\");\n\n            var readme2 = dir.path().resolve(\"README_2\");\n            Files.write(readme2, List.of(\"2\"));\n            r.add(readme2);\n            var second = r.commit(\"Added README_2\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme2, List.of(\"3\"), WRITE, APPEND);\n            r.add(readme2);\n            var third = r.commit(\"Modified README_2\", \"duke\", \"duke@openjdk.org\");\n\n            var metadata = r.commitMetadata(List.of(Path.of(\"README_1\")));\n            assertEquals(1, metadata.size());\n            assertEquals(first, metadata.get(0).hash());\n\n            metadata = r.commitMetadata(List.of(Path.of(\"README_2\")));\n            assertEquals(2, metadata.size());\n            assertEquals(third, metadata.get(0).hash());\n            assertEquals(second, metadata.get(1).hash());\n\n            metadata = r.commitMetadata(List.of(Path.of(\"README_1\"), Path.of(\"README_2\")));\n            assertEquals(3, metadata.size());\n            assertEquals(third, metadata.get(0).hash());\n            assertEquals(second, metadata.get(1).hash());\n            assertEquals(first, metadata.get(2).hash());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testCommitMetadataWithReverse(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n            r.add(readme);\n            var first = r.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"One more line\"), WRITE, APPEND);\n            r.add(readme);\n            var second = r.commit(\"Modified README\", \"duke\", \"duke@openjdk.org\");\n\n            var metadata = r.commitMetadata();\n            assertEquals(2, metadata.size());\n            assertEquals(second, metadata.get(0).hash());\n            assertEquals(first, metadata.get(1).hash());\n\n            metadata = r.commitMetadata(true);\n            assertEquals(2, metadata.size());\n            assertEquals(first, metadata.get(0).hash());\n            assertEquals(second, metadata.get(1).hash());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testTrivialMerge(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n            r.add(readme);\n            var first = r.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"One more line\"), WRITE, APPEND);\n            r.add(readme);\n            var second = r.commit(\"Modified README\", \"duke\", \"duke@openjdk.org\");\n\n            r.checkout(first, false);\n\n            var contributing = dir.path().resolve(\"CONTRIBUTING\");\n            Files.write(contributing, List.of(\"Send those patches!\"));\n            r.add(contributing);\n            var third = r.commit(\"Added contributing\", \"duke\", \"duke@openjdk.org\");\n\n            r.merge(second);\n            r.commit(\"Merge\", \"duke\", \"duke@openjdk.org\");\n\n            var refspec = vcs == VCS.GIT ? r.head().hex() : r.head().hex() + \":0\";\n            var commits = r.commits(refspec).asList();\n\n            assertEquals(4, commits.size());\n\n            var merge = commits.get(0);\n            assertEquals(List.of(\"Merge\"), merge.message());\n\n            var parents = new HashSet<>(merge.parents());\n            assertEquals(2, parents.size());\n            assertTrue(parents.contains(second));\n            assertTrue(parents.contains(third));\n\n            var diffs = merge.parentDiffs();\n            assertEquals(2, diffs.size());\n\n            var diff1 = diffs.get(0);\n            assertEquals(merge.hash(), diff1.to());\n            assertEquals(0, diff1.patches().size());\n            assertTrue(parents.contains(diff1.from()));\n\n            var diff2 = diffs.get(1);\n            assertEquals(merge.hash(), diff2.to());\n            assertEquals(0, diff2.patches().size());\n            assertTrue(parents.contains(diff2.from()));\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testMergeWithEdit(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n            r.add(readme);\n            var first = r.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"One more line\"), WRITE, APPEND);\n            r.add(readme);\n            var second = r.commit(\"Modified README\", \"duke\", \"duke@openjdk.org\");\n\n            r.checkout(first, false);\n\n            var contributing = dir.path().resolve(\"CONTRIBUTING\");\n            Files.write(contributing, List.of(\"Send those patches!\"));\n            r.add(contributing);\n            var third = r.commit(\"Added contributing\", \"duke\", \"duke@openjdk.org\");\n\n            r.merge(second);\n\n            Files.write(readme, List.of(\"One last line\"), WRITE, APPEND);\n            r.add(readme);\n            r.commit(\"Merge\", \"duke\", \"duke@openjdk.org\");\n\n            var refspec = vcs == VCS.GIT ? r.head().hex() : r.head().hex() + \":0\";\n            var commits = r.commits(refspec).asList();\n\n            assertEquals(4, commits.size());\n\n            var merge = commits.get(0);\n            assertEquals(List.of(\"Merge\"), merge.message());\n\n            var parents = new HashSet<>(merge.parents());\n            assertEquals(2, parents.size());\n            assertTrue(parents.contains(second));\n            assertTrue(parents.contains(third));\n\n            var diffs = merge.parentDiffs();\n            assertEquals(2, diffs.size());\n\n            var secondDiff = diffs.stream().filter(d -> d.from().equals(second)).findFirst().get();\n            assertEquals(merge.hash(), secondDiff.to());\n            assertEquals(1, secondDiff.patches().size());\n            var secondPatch = secondDiff.patches().get(0).asTextualPatch();\n\n            assertEquals(Path.of(\"README\"), secondPatch.source().path().get());\n            assertEquals(Path.of(\"README\"), secondPatch.target().path().get());\n            assertTrue(secondPatch.status().isModified());\n            assertEquals(1, secondPatch.hunks().size());\n\n            var secondHunk = secondPatch.hunks().get(0);\n            assertLinesEquals(List.of(), secondHunk.source().lines());\n            assertLinesEquals(List.of(\"One last line\"), secondHunk.target().lines());\n\n            assertEquals(3, secondHunk.source().range().start());\n            assertEquals(0, secondHunk.source().range().count());\n            assertEquals(3, secondHunk.target().range().start());\n            assertEquals(1, secondHunk.target().range().count());\n\n            var thirdDiff = diffs.stream().filter(d -> d.from().equals(third)).findFirst().get();\n            assertEquals(merge.hash(), thirdDiff.to());\n            assertEquals(1, thirdDiff.patches().size());\n            var thirdPatch = thirdDiff.patches().get(0).asTextualPatch();\n\n            assertEquals(Path.of(\"README\"), thirdPatch.source().path().get());\n            assertEquals(Path.of(\"README\"), thirdPatch.target().path().get());\n            assertTrue(thirdPatch.status().isModified());\n            assertEquals(1, thirdPatch.hunks().size());\n\n            var thirdHunk = thirdPatch.hunks().get(0);\n            assertLinesEquals(List.of(), thirdHunk.source().lines());\n            assertLinesEquals(List.of(\"One more line\", \"One last line\"), thirdHunk.target().lines());\n\n            assertEquals(2, thirdHunk.source().range().start());\n            assertEquals(0, thirdHunk.source().range().count());\n            assertEquals(2, thirdHunk.target().range().start());\n            assertEquals(2, thirdHunk.target().range().count());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testDefaultBranch(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            var expected = vcs == VCS.GIT ? \"master\" : \"default\";\n            assertEquals(expected, r.defaultBranch().name());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testPaths(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            var remote = vcs == VCS.GIT ? \"origin\" : \"default\";\n            r.setPaths(remote, \"http://pull\", \"http://push\");\n            assertEquals(\"http://pull\", r.pullPath(remote));\n            assertEquals(\"http://push\", r.pushPath(remote));\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testIsValidRevisionRange(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertFalse(r.isValidRevisionRange(\"foo\"));\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n            r.add(readme);\n            r.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n\n            assertTrue(r.isValidRevisionRange(r.defaultBranch().toString()));\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testDefaultTag(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            var expected = vcs == VCS.GIT ? Optional.empty() : Optional.of(new Tag(\"tip\"));\n            assertEquals(expected, r.defaultTag());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testTag(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n            r.add(readme);\n            var first = r.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n\n            r.tag(first, \"test\", \"Tagging test\", \"duke\", \"duke@openjdk.org\");\n            var defaultTag = r.defaultTag().orElse(null);\n            var nonDefaultTags = r.tags().stream()\n                                  .filter(tag -> !tag.equals(defaultTag))\n                                  .map(Tag::toString)\n                                  .collect(Collectors.toList());\n            assertEquals(List.of(\"test\"), nonDefaultTags);\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testIsClean(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n            assertFalse(r.isClean());\n\n            r.add(readme);\n            assertFalse(r.isClean());\n\n            r.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n            assertTrue(r.isClean());\n\n            Files.delete(readme);\n            assertFalse(r.isClean());\n\n            Files.write(readme, List.of(\"Hello, world!\"));\n            assertTrue(r.isClean());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testShowOnExecutableFiles(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var readOnlyExecutableFile = dir.path().resolve(\"hello.sh\");\n            Files.write(readOnlyExecutableFile, List.of(\"echo 'hello'\"));\n            if (readOnlyExecutableFile.getFileSystem().supportedFileAttributeViews().contains(\"posix\")) {\n                var permissions = PosixFilePermissions.fromString(\"r-xr-xr-x\");\n                Files.setPosixFilePermissions(readOnlyExecutableFile, permissions);\n            }\n            r.add(readOnlyExecutableFile);\n            var hash = r.commit(\"Added read only executable file\", \"duke\", \"duke@openjdk.org\");\n            assertEquals(Optional.of(List.of(\"echo 'hello'\")), r.lines(readOnlyExecutableFile, hash));\n\n            var readWriteExecutableFile = dir.path().resolve(\"goodbye.sh\");\n            Files.write(readWriteExecutableFile, List.of(\"echo 'goodbye'\"));\n            if (readOnlyExecutableFile.getFileSystem().supportedFileAttributeViews().contains(\"posix\")) {\n                var permissions = PosixFilePermissions.fromString(\"rwxrwxrwx\");\n                Files.setPosixFilePermissions(readWriteExecutableFile, permissions);\n            }\n            r.add(readWriteExecutableFile);\n            var hash2 = r.commit(\"Added read-write executable file\", \"duke\", \"duke@openjdk.org\");\n            assertEquals(Optional.of(List.of(\"echo 'goodbye'\")), r.lines(readWriteExecutableFile, hash2));\n        }\n    }\n\n    @Test\n    void testGetAndExistsOnNonExistingDirectory() throws IOException {\n        var nonExistingDirectory = Path.of(\"this\", \"does\", \"not\", \"exist\");\n        assertEquals(Optional.empty(), Repository.get(nonExistingDirectory));\n        assertEquals(false, Repository.exists(nonExistingDirectory));\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testDiffOnFilenamesWithSpace(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var fileWithSpaceInName = dir.path().resolve(\"hello world.txt\");\n            Files.writeString(fileWithSpaceInName, \"Hello world\\n\");\n            r.add(fileWithSpaceInName);\n            var hash1 = r.commit(\"Added file with space in name\", \"duke\", \"duke@openjdk.org\");\n            Files.writeString(fileWithSpaceInName, \"Goodbye world\\n\");\n            r.add(fileWithSpaceInName);\n            var hash2 = r.commit(\"Modified file with space in name\", \"duke\", \"duke@openjdk.org\");\n            var diff = r.diff(hash1, hash2);\n            var patches = diff.patches();\n            assertEquals(1, patches.size());\n            var patch = patches.get(0);\n            assertTrue(patch.target().path().isPresent());\n            var path = patch.target().path().get();\n            assertEquals(Path.of(\"hello world.txt\"), path);\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testDiffAgainstInitialRevision(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var readme = dir.path().resolve(\"README.md\");\n            Files.writeString(readme, \"Hello world\\n\");\n            r.add(readme);\n            var hash = r.commit(\"Added readme\", \"duke\", \"duke@openjdk.org\");\n            var commit = r.lookup(hash).orElseThrow();\n            var parent = commit.parents().get(0);\n\n            var diff = r.diff(parent, commit.hash());\n            assertEquals(parent, diff.from());\n            assertEquals(hash, diff.to());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testStatusAgainstInitialRevision(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var readme = dir.path().resolve(\"README.md\");\n            Files.writeString(readme, \"Hello world\\n\");\n            r.add(readme);\n            var hash = r.commit(\"Added readme\", \"duke\", \"duke@openjdk.org\");\n            var commit = r.lookup(hash).orElseThrow();\n            var parent = commit.parents().get(0);\n\n            var entries = r.status(parent, commit.hash());\n            assertEquals(1, entries.size());\n            var entry = entries.get(0);\n            assertTrue(entry.status().isAdded());\n            assertEquals(Path.of(\"README.md\"), entry.target().path().get());\n        }\n    }\n\n    @Test\n    void testSingleEmptyCommit() throws IOException, InterruptedException {\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), VCS.GIT);\n            assertTrue(r.isClean());\n\n            // must ust git directly to be able to pass --allow-empty\n            var pb = new ProcessBuilder(\"git\", \"commit\", \"--message\", \"An empty commit\", \"--allow-empty\");\n            pb.environment().put(\"GIT_AUTHOR_NAME\", \"duke\");\n            pb.environment().put(\"GIT_AUTHOR_EMAIL\", \"duke@openjdk.org\");\n            pb.environment().put(\"GIT_COMMITTER_NAME\", \"duke\");\n            pb.environment().put(\"GIT_COMMITTER_EMAIL\", \"duke@openjdk.org\");\n            pb.directory(dir.path().toFile());\n            pb.environment().putAll(GitRepository.currentEnv);\n\n            var res = pb.start().waitFor();\n            assertEquals(0, res);\n\n            var commits = r.commits().asList();\n            assertEquals(1, commits.size());\n            var commit = commits.get(0);\n            assertEquals(\"duke\", commit.author().name());\n            assertEquals(\"duke@openjdk.org\", commit.author().email());\n            assertEquals(\"duke\", commit.committer().name());\n            assertEquals(\"duke@openjdk.org\", commit.committer().email());\n            assertEquals(List.of(\"An empty commit\"), commit.message());\n        }\n    }\n\n    @Test\n    void testEmptyCommitWithParent() throws IOException, InterruptedException {\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), VCS.GIT);\n            assertTrue(r.isClean());\n\n            var f = Files.createFile(dir.path().resolve(\"hello.txt\"));\n            Files.writeString(f, \"Hello world\\n\");\n            r.add(f);\n            r.commit(\"Initial commit\", \"duke\", \"duke@openjdk.org\");\n\n            // must ust git directly to be able to pass --allow-empty\n            var pb = new ProcessBuilder(\"git\", \"commit\", \"--message\", \"An empty commit\", \"--allow-empty\");\n            pb.environment().put(\"GIT_AUTHOR_NAME\", \"duke\");\n            pb.environment().put(\"GIT_AUTHOR_EMAIL\", \"duke@openjdk.org\");\n            pb.environment().put(\"GIT_COMMITTER_NAME\", \"duke\");\n            pb.environment().put(\"GIT_COMMITTER_EMAIL\", \"duke@openjdk.org\");\n            pb.directory(dir.path().toFile());\n            pb.environment().putAll(GitRepository.currentEnv);\n\n            var res = pb.start().waitFor();\n            assertEquals(0, res);\n\n            var commits = r.commits().asList();\n            assertEquals(2, commits.size());\n            var commit = commits.get(0);\n            assertEquals(\"duke\", commit.author().name());\n            assertEquals(\"duke@openjdk.org\", commit.author().email());\n            assertEquals(\"duke\", commit.committer().name());\n            assertEquals(\"duke@openjdk.org\", commit.committer().email());\n            assertEquals(List.of(\"An empty commit\"), commit.message());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testAmend(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var f = dir.path().resolve(\"README\");\n            Files.writeString(f, \"Hello\\n\");\n            r.add(f);\n            r.commit(\"Initial commit\", \"duke\", \"duke@openjdk.org\");\n\n            Files.writeString(f, \"Hello, world\\n\");\n            r.add(f);\n            r.amend(\"Initial commit corrected\", \"duke\", \"duke@openjdk.org\");\n            var commits = r.commits().asList();\n            assertEquals(1, commits.size());\n            var commit = commits.get(0);\n            assertEquals(List.of(\"Initial commit corrected\"), commit.message());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testRevert(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var f = dir.path().resolve(\"README\");\n            Files.writeString(f, \"Hello\\n\");\n            r.add(f);\n            var initial = r.commit(\"Initial commit\", \"duke\", \"duke@openjdk.org\");\n\n            Files.writeString(f, \"Hello, world\\n\");\n            r.revert(initial);\n            Files.writeString(f, \"Goodbye, world\\n\");\n            r.add(f);\n            var hash = r.commit(\"Second commit\", \"duke\", \"duke@openjdk.org\");\n            var commit = r.lookup(hash).orElseThrow();\n            var patches = commit.parentDiffs().get(0).patches();\n            assertEquals(1, patches.size());\n            var patch = patches.get(0).asTextualPatch();\n            assertEquals(1, patch.hunks().size());\n            var hunk = patch.hunks().get(0);\n            assertEquals(List.of(\"Goodbye, world\"), hunk.target().lines());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testFiles(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var f = dir.path().resolve(\"README\");\n            Files.writeString(f, \"Hello\\n\");\n            r.add(f);\n            var initial = r.commit(\"Initial commit\", \"duke\", \"duke@openjdk.org\");\n\n            var entries = r.files(initial);\n            assertEquals(1, entries.size());\n            var entry = entries.get(0);\n            assertEquals(Path.of(\"README\"), entry.path());\n            assertTrue(entry.type().isRegularNonExecutable());\n            assertFalse(entry.hash().equals(Hash.zero()));\n\n            var f2 = dir.path().resolve(\"CONTRIBUTING\");\n            Files.writeString(f2, \"Hello\\n\");\n            r.add(f2);\n            var second = r.commit(\"Second commit\", \"duke\", \"duke@openjdk.org\");\n\n            entries = r.files(second);\n            assertEquals(2, entries.size());\n            assertTrue(entries.stream().allMatch(e -> e.type().isRegularNonExecutable()));\n            assertTrue(entries.stream().noneMatch(e -> e.hash().equals(Hash.zero())));\n            var paths = entries.stream().map(FileEntry::path).collect(Collectors.toSet());\n            assertTrue(paths.contains(Path.of(\"README\")));\n            assertTrue(paths.contains(Path.of(\"CONTRIBUTING\")));\n\n            entries = r.files(second, Path.of(\"README\"));\n            assertEquals(1, entries.size());\n            entry = entries.get(0);\n            assertEquals(Path.of(\"README\"), entry.path());\n            assertTrue(entry.type().isRegularNonExecutable());\n            assertFalse(entry.hash().equals(Hash.zero()));\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testDump(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var f = dir.path().resolve(\"README\");\n            Files.writeString(f, \"Hello\\n\");\n            r.add(f);\n            var initial = r.commit(\"Initial commit\", \"duke\", \"duke@openjdk.org\");\n\n            var readme = r.files(initial).get(0);\n\n            var tmp = Files.createTempFile(\"README\", \"txt\");\n            r.dump(readme, tmp);\n            assertEquals(\"Hello\\n\", Files.readString(tmp));\n            Files.delete(tmp);\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testStatus(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var f = dir.path().resolve(\"README\");\n            Files.writeString(f, \"Hello\\n\");\n            r.add(f);\n            var initial = r.commit(\"Initial commit\", \"duke\", \"duke@openjdk.org\");\n\n            var f2 = dir.path().resolve(\"CONTRIBUTING\");\n            Files.writeString(f2, \"Goodbye\\n\");\n            r.add(f2);\n            var second = r.commit(\"Second commit\", \"duke\", \"duke@openjdk.org\");\n\n            var entries = r.status(initial, second);\n            assertEquals(1, entries.size());\n            var entry = entries.get(0);\n            assertTrue(entry.status().isAdded());\n            assertTrue(entry.source().path().isEmpty());\n            assertTrue(entry.source().type().isEmpty());\n\n            assertTrue(entry.target().path().isPresent());\n            assertEquals(Path.of(\"CONTRIBUTING\"), entry.target().path().get());\n            assertTrue(entry.target().type().get().isRegular());\n        }\n    }\n\n    // Mercurial doesn't seem like to unicode filenames on Windows\n    @Test\n    void testStatusWithUnicodeFiles() throws IOException {\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), VCS.GIT);\n            assertTrue(r.isClean());\n\n            var f = dir.path().resolve(\"REÁDME.md\");\n            Files.writeString(f, \"Hello\\n\");\n            r.add(f);\n            var first = r.commit(\"Add readme\", \"duke\", \"duke@openjdk.org\");\n\n            Files.writeString(f, \"Hello\\nWorld\\n\");\n            r.add(f);\n            var second = r.commit(\"Update readme\", \"duke\", \"duke@openjdk.org\");\n\n            var entries = r.status(first, second);\n            assertEquals(1, entries.size());\n            var entry = entries.get(0);\n            assertTrue(entry.status().isModified());\n            if (System.getProperty(\"os.name\").toLowerCase().startsWith(\"mac\")) {\n                // On macos, the default filesystem APFS is normalization-insensitive yet\n                // normalization-preserving. Because of this, Git has a commonly enabled\n                // feature 'core.precomposeUnicode' which normalizes unicode to composite\n                // form. Because of this, we cannot trust that the path object returned\n                // from status is equal to a path object created here with the same\n                // original filename. We need to instead compare the NFC normalized\n                // strings.\n                assertEquals(Normalizer.normalize(\"REÁDME.md\", Normalizer.Form.NFC),\n                        Normalizer.normalize(entry.target().path().orElseThrow().toString(), Normalizer.Form.NFC));\n                // Also check that the filesystem resolves the file as returned by Git.\n                assertTrue(Files.exists(dir.path().resolve(entry.target().path().orElseThrow())));\n            } else {\n                assertEquals(Path.of(\"REÁDME.md\"), entry.target().path().orElseThrow());\n            }\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testTrackLineEndings(VCS vcs) throws IOException, InterruptedException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            if (vcs == VCS.GIT) { // turn of git's meddling\n                int exitCode = new ProcessBuilder()\n                        .command(\"git\", \"config\", \"--local\", \"core.autocrlf\", \"false\")\n                        .directory(dir.path().toFile())\n                        .start()\n                        .waitFor();\n                assertEquals(0, exitCode);\n            }\n\n            var readme = dir.path().resolve(\"README\");\n            Files.writeString(readme, \"Line with Unix line ending\\n\");\n            Files.writeString(readme, \"Line with Windows line ending\\r\\n\", APPEND);\n\n            r.add(readme);\n            r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            var commits = r.commits().asList();\n            assertEquals(1, commits.size());\n\n            var commit = commits.get(0);\n            var diffs = commit.parentDiffs();\n            var diff = diffs.get(0);\n            assertEquals(2, diff.totalStats().added());\n\n            var patches = diff.patches();\n            assertEquals(1, patches.size());\n\n            var patch = patches.get(0).asTextualPatch();\n            var hunks = patch.hunks();\n            assertEquals(1, hunks.size());\n\n            var hunk = hunks.get(0);\n            assertEquals(new Range(0, 0), hunk.source().range());\n            assertEquals(new Range(1, 2), hunk.target().range());\n\n            assertEquals(\n                    List.of(\"Line with Unix line ending\", \"Line with Windows line ending\\r\"),\n                    hunk.target().lines());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testContains(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var f = dir.path().resolve(\"README\");\n            Files.writeString(f, \"Hello\\n\");\n            r.add(f);\n            var initial = r.commit(\"Initial commit\", \"duke\", \"duke@openjdk.org\");\n\n            assertTrue(r.contains(r.defaultBranch(), initial));\n\n            Files.writeString(f, \"Hello again\\n\");\n            r.add(f);\n            var second = r.commit(\"Second commit\", \"duke\", \"duke@openjdk.org\");\n\n            assertTrue(r.contains(r.defaultBranch(), initial));\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testAbortMerge(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var f = dir.path().resolve(\"README\");\n            Files.writeString(f, \"Hello\\n\");\n            r.add(f);\n            var initial = r.commit(\"Initial commit\", \"duke\", \"duke@openjdk.org\");\n\n            Files.writeString(f, \"Hello again\\n\");\n            r.add(f);\n            var second = r.commit(\"Second commit\", \"duke\", \"duke@openjdk.org\");\n\n            r.checkout(initial);\n            Files.writeString(f, \"Conflicting hello\\n\");\n            r.add(f);\n            var third = r.commit(\"Third commit\", \"duke\", \"duke@openjdk.org\");\n\n            assertThrows(IOException.class, () -> { r.merge(second); });\n\n            r.abortMerge();\n            assertTrue(r.isClean());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testReset(VCS vcs) throws IOException {\n        assumeTrue(vcs == VCS.GIT); // FIXME reset is not yet implemented for HG\n\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path(), vcs);\n            assertTrue(repo.isClean());\n\n            var f = dir.path().resolve(\"README\");\n            Files.writeString(f, \"Hello\\n\");\n            repo.add(f);\n            var initial = repo.commit(\"Initial commit\", \"duke\", \"duke@openjdk.org\");\n\n            Files.writeString(f, \"Hello again\\n\");\n            repo.add(f);\n            var second = repo.commit(\"Second commit\", \"duke\", \"duke@openjdk.org\");\n\n            assertEquals(second, repo.head());\n            assertEquals(2, repo.commits().asList().size());\n\n            repo.reset(initial, true);\n\n            assertEquals(initial, repo.head());\n            assertEquals(1, repo.commits().asList().size());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testRemotes(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path(), vcs);\n            assertEquals(List.of(), repo.remotes());\n            repo.addRemote(\"foobar\", \"https://foo/bar\");\n            assertEquals(List.of(\"foobar\"), repo.remotes());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testRemoteBranches(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n\n        // Skip HG if ls-remote extension is not available\n        if (vcs == VCS.HG) {\n            try {\n                var pb = new ProcessBuilder(\"hg\", \"ls-remote\", \"--help\");\n                pb.redirectErrorStream(true);\n                var process = pb.start();\n                process.waitFor();\n                assumeTrue(process.exitValue() == 0, \"hg ls-remote extension not available\");\n            } catch (Exception e) {\n                assumeTrue(false, \"hg ls-remote extension check failed\");\n            }\n        }\n\n        try (var dir = new TemporaryDirectory()) {\n            var upstream = TestableRepository.init(dir.path().resolve(\"upstream\"), vcs);\n            var readme = upstream.root().resolve(\"README\");\n            Files.writeString(readme, \"Hello\\n\");\n            upstream.add(readme);\n            var head = upstream.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n\n            var fork = TestableRepository.init(dir.path().resolve(\"fork\"), vcs);\n            fork.addRemote(\"upstream\", upstream.root().toUri().toString());\n            var refs = fork.remoteBranches(\"upstream\");\n            assertEquals(1, refs.size());\n            var ref = refs.get(0);\n            assertEquals(head, ref.hash());\n            assertEquals(upstream.defaultBranch().name(), ref.name());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testSubmodulesOnEmptyRepo(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path(), vcs);\n            assertEquals(List.of(), repo.submodules());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testSubmodulesOnRepoWithNoSubmodules(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path().resolve(\"repo\"), vcs);\n            var readme = repo.root().resolve(\"README\");\n            Files.writeString(readme, \"Hello\\n\");\n            repo.add(readme);\n            repo.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n            assertEquals(List.of(), repo.submodules());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testSubmodulesOnRepoWithSubmodule(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var submodule = TestableRepository.init(dir.path().resolve(\"submodule\"), vcs);\n            var readme = submodule.root().resolve(\"README\");\n            Files.writeString(readme, \"Hello\\n\");\n            submodule.add(readme);\n            var head = submodule.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n\n            var repo = TestableRepository.init(dir.path().resolve(\"repo\"), vcs);\n            var pullPath = submodule.root().toAbsolutePath().toString();\n            repo.addSubmodule(pullPath, Path.of(\"sub\"));\n            repo.commit(\"Added submodule\", \"duke\", \"duke@openjdk.org\");\n\n            var submodules = repo.submodules();\n            assertEquals(1, submodules.size());\n            var module = submodules.get(0);\n            assertEquals(Path.of(\"sub\"), module.path());\n            assertEquals(head, module.hash());\n            assertEquals(pullPath, module.pullPath());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testAnnotateTag(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory(false)) {\n            var repo = TestableRepository.init(dir.path(), vcs);\n            var readme = repo.root().resolve(\"README\");\n            var now = ZonedDateTime.now();\n            Files.writeString(readme, \"Hello\\n\");\n            repo.add(readme);\n            var head = repo.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n            var tag = repo.tag(head, \"1.0\", \"Added tag 1.0 for HEAD\", \"duke\", \"duke@openjdk.org\");\n            var annotated = repo.annotate(tag).get();\n\n            assertEquals(\"1.0\", annotated.name());\n            assertEquals(head, annotated.target());\n            assertEquals(new Author(\"duke\", \"duke@openjdk.org\"), annotated.author());\n            assertEquals(now.getYear(), annotated.date().getYear());\n            assertEquals(now.getMonth(), annotated.date().getMonth());\n            assertEquals(now.getDayOfYear(), annotated.date().getDayOfYear());\n            assertEquals(now.getHour(), annotated.date().getHour());\n            assertEquals(now.getOffset(), annotated.date().getOffset());\n            assertEquals(\"Added tag 1.0 for HEAD\", annotated.message());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testAnnotateTagOnMissingTag(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path(), vcs);\n            var readme = repo.root().resolve(\"README\");\n            Files.writeString(readme, \"Hello\\n\");\n            repo.add(readme);\n            var head = repo.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n\n            assertEquals(Optional.empty(), repo.annotate(new Tag(\"unknown\")));\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testAnnotateTagOnEmptyRepo(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path(), vcs);\n            assertEquals(Optional.empty(), repo.annotate(new Tag(\"unknown\")));\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testDiffWithFileList(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path(), vcs);\n            var readme = repo.root().resolve(\"README\");\n            Files.writeString(readme, \"Hello\\n\");\n            repo.add(readme);\n\n            var contribute = repo.root().resolve(\"CONTRIBUTE\");\n            Files.writeString(contribute, \"1. Make changes\\n\");\n            repo.add(contribute);\n\n            var first = repo.commit(\"Added README and CONTRIBUTE\", \"duke\", \"duke@openjdk.org\");\n            Files.writeString(readme, \"World\\n\", WRITE, APPEND);\n            Files.writeString(contribute, \"2. Run git commit\", WRITE, APPEND);\n\n            var diff = repo.diff(first, List.of(Path.of(\"README\")));\n            var diffStats = diff.totalStats();\n            assertEquals(1, diffStats.added());\n            assertEquals(0, diffStats.modified());\n            assertEquals(0, diffStats.removed());\n            var patches = diff.patches();\n            assertEquals(1, patches.size());\n            var patch = patches.get(0);\n            assertTrue(patch.isTextual());\n            assertTrue(patch.status().isModified());\n            assertEquals(Path.of(\"README\"), patch.source().path().get());\n            assertEquals(Path.of(\"README\"), patch.target().path().get());\n\n            repo.add(readme);\n            repo.add(contribute);\n            var second = repo.commit(\"Updates to both README and CONTRIBUTE\", \"duke\", \"duke@openjdk.org\");\n\n            diff = repo.diff(first, second, List.of(Path.of(\"CONTRIBUTE\")));\n            diffStats = diff.totalStats();\n            assertEquals(1, diffStats.added());\n            assertEquals(0, diffStats.modified());\n            assertEquals(0, diffStats.removed());\n            patches = diff.patches();\n            assertEquals(1, patches.size());\n            patch = patches.get(0);\n            assertTrue(patch.isTextual());\n            assertTrue(patch.status().isModified());\n            assertEquals(Path.of(\"CONTRIBUTE\"), patch.source().path().get());\n            assertEquals(Path.of(\"CONTRIBUTE\"), patch.target().path().get());\n\n            diff = repo.diff(first, second, List.of(Path.of(\"DOES_NOT_EXIST\")));\n            assertEquals(0, diff.patches().size());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testWritingConfigValue(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path(), vcs);\n            assertEquals(List.of(), repo.config(\"test.key\"));\n            repo.config(\"test\", \"key\", \"value\");\n            assertEquals(List.of(\"value\"), repo.config(\"test.key\"));\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testNoConfig(VCS vcs) throws IOException, InterruptedException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        // Verify that our method of disabling configuration works\n        try (var dir = new TemporaryDirectory()) {\n            switch (vcs) {\n                case GIT -> {\n                    var gitRepo = new GitRepository(dir.path()).init();\n                    try (var p = GitRepository.capture(dir.path(),\n                            \"git\", \"config\", \"--list\")) {\n                        var configResult = p.await();\n                        assertEquals(configResult.status(), 0);\n                        // We can't get a list of all settings except local, so compare all with local only\n                        try (var p1 = GitRepository.capture(dir.path(),\n                                \"git\", \"config\", \"--list\", \"--local\")) {\n                            var localConfigResult = p1.await();\n                            assertEquals(localConfigResult.status(), 0);\n                            assertEquals(localConfigResult.stdout(), configResult.stdout());\n                        }\n                    }\n                }\n\n                case HG -> {\n                    var hgRepo = new HgRepository(dir.path()).init();\n                    try (var p = HgRepository.capture(dir.path(),\n                            \"hg\", \"config\")) {\n                        var settingsResult = p.await();\n                        assertEquals(settingsResult.status(), 0);\n                        // There's no way to stop hg from picking up ui.editor or repo settings,\n                        // nor to print only them, so hard-code these settings.\n                        var filteredSettings = settingsResult.stdout().stream().filter(\n                                s -> !(s.startsWith(\"bundle.mainreporoot=\") || s.startsWith(\"ui.editor=\"))\n                        ).toArray();\n                        assertTrue(filteredSettings.length == 0);\n                    }\n                }\n            }\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testFetchRemote(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var upstream = TestableRepository.init(dir.path(), vcs);\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            upstream.add(readme);\n            upstream.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            try (var dir2 = new TemporaryDirectory()) {\n                var downstream = TestableRepository.init(dir2.path(), vcs);\n\n                 // note: forcing unix path separators for URI\n                var upstreamURI = URI.create(\"file:///\" + dir.toString().replace('\\\\', '/'));\n                downstream.addRemote(\"upstream\", upstreamURI.toString());\n                downstream.addRemote(\"foobar\", \"file:///this/path/does/not/exist\");\n                downstream.fetchRemote(\"upstream\");\n            }\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testPrune(VCS vcs) throws IOException {\n        assumeTrue(vcs == VCS.GIT); // FIXME hard to test with hg due to bookmarks and branches\n        try (var dir = new TemporaryDirectory(false)) {\n            var upstreamDir = dir.path().resolve(\"upstream\" + (vcs == VCS.GIT ? \".git\" : \".hg\"));\n            var upstream = TestableRepository.init(upstreamDir, vcs);\n\n            Files.write(upstream.root().resolve(\".git\").resolve(\"config\"),\n                        List.of(\"[receive]\", \"denyCurrentBranch=ignore\"),\n                        WRITE, APPEND);\n\n            var readme = upstreamDir.resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            upstream.add(readme);\n            var head = upstream.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n            var branch = upstream.branch(head, \"foo\");\n            var upstreamBranches = upstream.branches();\n            assertEquals(2, upstreamBranches.size());\n            assertTrue(upstreamBranches.contains(branch));\n\n            var upstreamURI = URI.create(\"file:///\" + upstreamDir.toString().replace('\\\\', '/'));\n            var downstreamDir = dir.path().resolve(\"downstream\");\n            var downstream = Repository.clone(upstreamURI, downstreamDir);\n\n            // Ensure that 'foo' branch is materialized downstream\n            downstream.checkout(branch);\n            downstream.checkout(downstream.defaultBranch());\n\n            var remotes = downstream.remotes();\n            assertEquals(1, remotes.size());\n            var downstreamBranches = downstream.branches();\n            assertEquals(2, downstreamBranches.size());\n            assertEquals(downstreamBranches, upstreamBranches);\n\n            downstream.prune(branch, remotes.get(0));\n\n            downstreamBranches = downstream.branches();\n            assertEquals(1, downstreamBranches.size());\n            assertEquals(List.of(downstream.defaultBranch()), downstreamBranches);\n\n            upstreamBranches = upstream.branches();\n            assertEquals(1, upstreamBranches.size());\n            assertEquals(List.of(upstream.defaultBranch()), upstreamBranches);\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testUnmergedStatus(VCS vcs) throws IOException {\n        assumeTrue(vcs == VCS.GIT);\n        try (var dir = new TemporaryDirectory(false)) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n            r.add(readme);\n            var first = r.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"One more line\"), WRITE, APPEND);\n            r.add(readme);\n            var second = r.commit(\"Modified README\", \"duke\", \"duke@openjdk.org\");\n\n            r.checkout(first, false);\n\n            Files.write(readme, List.of(\"Another line\"), WRITE, APPEND);\n            r.add(readme);\n            var third = r.commit(\"Modified README again\", \"duke\", \"duke@openjdk.org\");\n\n            assertThrows(IOException.class, () -> { r.merge(second); });\n\n            var status = r.status();\n            for (var s : status) {\n                System.out.println(s.status() + \" \" + s.source().path().get());\n            }\n            assertEquals(2, status.size());\n            var statusEntry = status.get(0);\n            assertTrue(statusEntry.status().isUnmerged());\n            assertEquals(Path.of(\"README\"), statusEntry.source().path().get());\n            assertEquals(Optional.empty(), statusEntry.source().type());\n            assertEquals(Hash.zero(), statusEntry.source().hash());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testRangeSingle(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path(), vcs);\n            var range = repo.range(new Hash(\"0123456789\"));\n            if (vcs == VCS.GIT) {\n                assertEquals(\"0123456789^!\", range);\n            } else if (vcs == VCS.HG) {\n                assertEquals(\"0123456789\", range);\n            } else {\n                fail(\"Unexpected vcs: \" + vcs);\n            }\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testRangeInclusive(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path(), vcs);\n            var range = repo.rangeInclusive(new Hash(\"01234\"), new Hash(\"56789\"));\n            if (vcs == VCS.GIT) {\n                assertEquals(\"01234^..56789\", range);\n            } else if (vcs == VCS.HG) {\n                assertEquals(\"01234:56789\", range);\n            } else {\n                fail(\"Unexpected vcs: \" + vcs);\n            }\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testRangeExclusive(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path(), vcs);\n            var range = repo.rangeExclusive(new Hash(\"01234\"), new Hash(\"56789\"));\n            if (vcs == VCS.GIT) {\n                assertEquals(\"01234..56789\", range);\n            } else if (vcs == VCS.HG) {\n                assertEquals(\"01234:56789-01234\", range);\n            } else {\n                fail(\"Unexpected vcs: \" + vcs);\n            }\n        }\n    }\n\n    @Test\n    void testHgRepoNestedInGitRepo() throws IOException {\n        assumeTrue(hgAvailable);\n        try (var gitDir = new TemporaryDirectory()) {\n            var gitRepo = TestableRepository.init(gitDir.path(), VCS.GIT);\n            var gitFile = gitRepo.root().resolve(\"git-file.txt\");\n            Files.write(gitFile, List.of(\"Hello, Git!\"));\n            gitRepo.add(gitFile);\n            var gitHash = gitRepo.commit(\"Added git-file.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var hgDir = gitRepo.root().resolve(\"hg\");\n            var hgRepo = TestableRepository.init(hgDir, VCS.HG);\n            var hgFile = hgRepo.root().resolve(\"hg-file.txt\");\n            Files.write(hgFile, List.of(\"Hello, Mercurial!\"));\n            hgRepo.add(hgFile);\n            var hgHash = hgRepo.commit(\"Added hg-file.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var resolvedHgRepo = Repository.get(hgDir).orElseThrow();\n            var resolvedHgCommits = resolvedHgRepo.commits().asList();\n            assertEquals(1, resolvedHgCommits.size());\n            assertEquals(hgHash, resolvedHgCommits.get(0).hash());\n\n            var resolvedGitRepo = Repository.get(gitDir.path()).orElseThrow();\n            var resolvedGitCommits = resolvedGitRepo.commits().asList();\n            assertEquals(1, resolvedGitCommits.size());\n            assertEquals(gitHash, resolvedGitCommits.get(0).hash());\n        }\n    }\n\n    @Test\n    void testGitRepoNestedInHgRepo() throws IOException {\n        assumeTrue(hgAvailable);\n        try (var hgDir = new TemporaryDirectory()) {\n            var hgRepo = TestableRepository.init(hgDir.path(), VCS.HG);\n            var hgFile = hgRepo.root().resolve(\"hg-file.txt\");\n            Files.write(hgFile, List.of(\"Hello, Mercurial!\"));\n            hgRepo.add(hgFile);\n            var hgHash = hgRepo.commit(\"Added hg-file.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var gitDir = hgRepo.root().resolve(\"git\");\n            var gitRepo = TestableRepository.init(gitDir, VCS.GIT);\n            var gitFile = gitRepo.root().resolve(\"git-file.txt\");\n            Files.write(gitFile, List.of(\"Hello, Git!\"));\n            gitRepo.add(gitFile);\n            var gitHash = gitRepo.commit(\"Added git-file.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var resolvedHgRepo = Repository.get(hgDir.path()).orElseThrow();\n            var resolvedHgCommits = resolvedHgRepo.commits().asList();\n            assertEquals(1, resolvedHgCommits.size());\n            assertEquals(hgHash, resolvedHgCommits.get(0).hash());\n\n            var resolvedGitRepo = Repository.get(gitDir).orElseThrow();\n            var resolvedGitCommits = resolvedGitRepo.commits().asList();\n            assertEquals(1, resolvedGitCommits.size());\n            assertEquals(gitHash, resolvedGitCommits.get(0).hash());\n        }\n    }\n\n    @Test\n    void testGitAndHgRepoInSameDirectory() throws IOException {\n        assumeTrue(hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var hgRepo = TestableRepository.init(dir.path(), VCS.HG);\n            var hgFile = hgRepo.root().resolve(\"hg-file.txt\");\n            Files.write(hgFile, List.of(\"Hello, Mercurial!\"));\n            hgRepo.add(hgFile);\n            var hgHash = hgRepo.commit(\"Added hg-file.txt\", \"duke\", \"duke@openjdk.org\");\n\n            var gitRepo = TestableRepository.init(dir.path(), VCS.GIT);\n            var gitFile = gitRepo.root().resolve(\"git-file.txt\");\n            Files.write(gitFile, List.of(\"Hello, Git!\"));\n            gitRepo.add(gitFile);\n            var gitHash = gitRepo.commit(\"Added git-file.txt\", \"duke\", \"duke@openjdk.org\");\n\n            assertThrows(IOException.class, () -> Repository.get(dir.path()));\n        }\n    }\n\n    @Test\n    void testCommitterDate() throws IOException {\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path(), VCS.GIT);\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            repo.add(readme);\n            var authored = ZonedDateTime.parse(\"2020-06-15T14:27:13+02:00\");\n            var committed = authored.plusMinutes(10);\n            var head = repo.commit(\"Add README\",\n                                   \"author\", \"author@openjdk.org\", authored,\n                                   \"committer\", \"committer@openjdk.org\", committed);\n            var commit = repo.lookup(head).orElseThrow();\n            assertEquals(\"author\", commit.author().name());\n            assertEquals(\"author@openjdk.org\", commit.author().email());\n            assertEquals(authored, commit.authored());\n\n            assertEquals(\"committer\", commit.committer().name());\n            assertEquals(\"committer@openjdk.org\", commit.committer().email());\n            assertEquals(committed, commit.committed());\n        }\n    }\n\n    @Test\n    void testLightweightTags() throws IOException, InterruptedException {\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path(), VCS.GIT);\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            repo.add(readme);\n            var head = repo.commit(\"Add README\", \"author\", \"author@openjdk.org\");\n\n            // We don't want to expose making lightweight tags via the Repository class,\n            // so use a ProcessBuilder and invoke git directly here\n            var pb = new ProcessBuilder(\"git\", \"tag\", \"test-tag\", head.hex());\n            pb.directory(repo.root().toFile());\n            pb.environment().putAll(GitRepository.currentEnv);\n            assertEquals(0, pb.start().waitFor());\n\n            var tags = repo.tags();\n            assertEquals(1, tags.size());\n\n            var tag = tags.get(0);\n            assertEquals(\"test-tag\", tag.name());\n\n            // Lightweight tags can't be annotated\n            assertEquals(Optional.empty(), repo.annotate(tag));\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testMergeCommitWithRenamedP0AndModifiedP1(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory(false)) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README.old\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n            r.add(readme);\n            var first = r.commit(\"Added README.old\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"One more line\"), WRITE, APPEND);\n            r.add(readme);\n            var second = r.commit(\"Modified README.old\", \"duke\", \"duke@openjdk.org\");\n\n            r.checkout(first, false);\n            r.move(Path.of(\"README.old\"), Path.of(\"README.new\"));\n            var third = r.commit(\"Renamed README.old to README.new\", \"duke\", \"duke@openjdk.org\");\n\n            r.merge(second);\n            var hash = r.commit(\"Merge\", \"duke\", \"duke@openjdk.org\");\n            var merge = r.lookup(hash).orElseThrow();\n\n            var diffs = merge.parentDiffs();\n            assertEquals(2, diffs.size());\n\n            assertEquals(1, diffs.get(0).patches().size());\n            var p0 = diffs.get(0).patches().get(0);\n            assertTrue(p0.status().isModified());\n            assertEquals(Path.of(\"README.new\"), p0.source().path().get());\n            assertEquals(Path.of(\"README.new\"), p0.target().path().get());\n\n            assertEquals(1, diffs.get(1).patches().size());\n            var p1 = diffs.get(1).patches().get(0);\n            if (vcs == VCS.GIT) {\n                assertTrue(p1.status().isRenamed());\n            } else if (vcs == VCS.HG) {\n                assertTrue(p1.status().isCopied());\n            } else {\n                fail(\"Unknown VCS\");\n            }\n            assertEquals(Path.of(\"README.old\"), p1.source().path().get());\n            assertEquals(Path.of(\"README.new\"), p1.target().path().get());\n        }\n    }\n\n    @Test\n    void testMercurialTagWithoutEmail() throws IOException, InterruptedException {\n        assumeTrue(hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path(), VCS.HG);\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n            repo.add(readme);\n            var head = repo.commit(\"Add README\", \"author\", \"author@openjdk.org\");\n            var tag = repo.tag(head, \"1.0\", \"Add tag 1.0\", \"duke\", null);\n            var annotated = repo.annotate(tag).orElseThrow();\n            assertEquals(\"duke\", annotated.author().name());\n            assertNull(annotated.author().email());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testNonFastForwardMerge(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            var hash1 = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"Another line\"), WRITE, APPEND);\n            r.add(readme);\n            var hash2 = r.commit(\"Modify README\", \"duke\", \"duke@openjdk.org\");\n\n            r.checkout(hash1, false);\n            r.merge(hash2, Repository.FastForward.DISABLE);\n            var hash3 = r.commit(\"Non fast-forward merge\", \"duke\", \"duke@openjdk.org\");\n            var mergeCommit = r.lookup(hash3).orElseThrow();\n            assertEquals(2, mergeCommit.parents().size());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testFastForwardMerge(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            var hash1 = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n            var other = r.branch(hash1, \"other\");\n            r.checkout(other);\n\n            Files.write(readme, List.of(\"Another line\"), WRITE, APPEND);\n            r.add(readme);\n            var hash2 = r.commit(\"Modify README\", \"duke\", \"duke@openjdk.org\");\n\n            r.checkout(r.defaultBranch());\n            r.merge(hash2, Repository.FastForward.AUTO);\n            var diff = r.diff(r.head());\n            assertEquals(List.of(), diff.patches());\n            assertEquals(2, r.commits().asList().size());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testDeleteUntrackedFiles(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            var hash1 = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n            var untracked = dir.path().resolve(\"UNTRACKED\");\n            Files.write(untracked, List.of(\"Hello, untracked!\"));\n\n            try (var list = Files.list(r.root())) {\n                var paths = list.toList();\n                assertTrue(paths.contains(untracked));\n                assertTrue(paths.contains(readme));\n            }\n\n            r.deleteUntrackedFiles();\n            try (var list = Files.list(r.root())) {\n                var paths = list.toList();\n                assertFalse(paths.contains(untracked));\n                assertTrue(paths.contains(readme));\n            }\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testTimestampOnTags(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            var hash = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n            var date = ZonedDateTime.parse(\"2007-12-03T10:15:30+01:00\");\n            var tag = r.tag(hash, \"1.0\", \"Added tag 1.0\", \"duke\", \"duke@openjdk.org\", date);\n            var annotated = r.annotate(tag);\n            assertTrue(annotated.isPresent());\n            assertEquals(date, annotated.get().date());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testFollow(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            var first = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            var readme2 = dir.path().resolve(\"README2\");\n            r.move(readme, readme2);\n            var second = r.commit(\"Move README to README2\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme2, List.of(\"Hello, readme2!\"));\n            r.add(readme2);\n            var third = r.commit(\"Update README2\", \"duke\", \"duke@openjdk.org\");\n\n            var commits = r.follow(readme2);\n            var hashes = commits.stream().map(CommitMetadata::hash).collect(Collectors.toList());\n            assertEquals(List.of(third, second, first), hashes);\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testFollowMergeCommit(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory(false)) {\n            var r = TestableRepository.init(dir.path(), vcs);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            r.add(readme);\n            var first = r.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"Hello, again!!\"));\n            r.add(readme);\n            var second = r.commit(\"Update README\", \"duke\", \"duke@openjdk.org\");\n\n            r.checkout(first);\n            Files.write(readme, List.of(\"Greetings, world\"));\n            r.add(readme);\n            var third = r.commit(\"Update README concurrently\", \"duke\", \"duke@openjdk.org\");\n\n            if (vcs == VCS.GIT) {\n                r.checkout(r.defaultBranch());\n                r.merge(third, \"ours\", Repository.FastForward.DISABLE);\n            } else if (vcs == VCS.HG) {\n                r.checkout(second);\n                r.merge(third, \":local\", Repository.FastForward.DISABLE);\n            } else {\n                fail(\"Unexpected VCS: \" + vcs);\n            }\n            Files.write(readme, List.of(\"Resolve merge\"));\n            r.add(readme);\n            var merge = r.commit(\"Merge\", \"duke\", \"duke@openjdk.org\");\n\n            Files.write(readme, List.of(\"Final update\"));\n            r.add(readme);\n            var fourth = r.commit(\"Final README update\", \"duke\", \"duke@openjdk.org\");\n\n            var commits = r.follow(readme);\n            var hashes = commits.stream().map(CommitMetadata::hash).collect(Collectors.toList());\n            assertEquals(5, hashes.size());\n            assertTrue(hashes.contains(first));\n            assertTrue(hashes.contains(second));\n            assertTrue(hashes.contains(third));\n            assertTrue(hashes.contains(merge));\n            assertTrue(hashes.contains(fourth));\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testPull(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var upstream = TestableRepository.init(dir.path(), vcs);\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            upstream.add(readme);\n            var head = upstream.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n            upstream.tag(head, \"1.0\", \"Added tag 1.0\", \"duke\", \"duke@openjdk.org\");\n\n            try (var dir2 = new TemporaryDirectory()) {\n                var downstream = TestableRepository.init(dir2.path(), vcs);\n\n                 // note: forcing unix path separators for URI\n                var upstreamURI = URI.create(\"file:///\" + dir.toString().replace('\\\\', '/'));\n                if (vcs == VCS.GIT) {\n                    downstream.addRemote(\"origin\", upstreamURI.toString());\n                    downstream.pull(\"origin\", \"master\");\n                    assertEquals(1, downstream.commitMetadata().size());\n                    assertEquals(head, downstream.commitMetadata().get(0).hash());\n                    assertEquals(List.of(), downstream.tags());\n                    downstream.pull(\"origin\", \"master\", true);\n                    assertEquals(List.of(new Tag(\"1.0\")), downstream.tags());\n                } else {\n                    downstream.addRemote(\"default\", upstreamURI.toString());\n                    downstream.pull(\"default\");\n                    assertEquals(2, downstream.commitMetadata().size());\n                    assertEquals(head, downstream.commitMetadata().get(1).hash());\n                    assertEquals(List.of(new Tag(\"tip\"), new Tag(\"1.0\")), downstream.tags());\n                }\n            }\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testNonExistingLookup(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var readme = dir.path().resolve(\"README.md\");\n            Files.writeString(readme, \"Hello world\\n\");\n            r.add(readme);\n            var hash = r.commit(\"Added readme\", \"duke\", \"duke@openjdk.org\");\n\n            var nonExisting = r.lookup(new Hash(\"0123456789012345678901234567890123456789\"));\n            assertEquals(Optional.empty(), nonExisting);\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testSuccessfulCherryPicking(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var readme = dir.path().resolve(\"README.md\");\n            Files.writeString(readme, \"Hello world\\n\");\n            r.add(readme);\n            var initial = r.commit(\"Added readme\", \"duke\", \"duke@openjdk.org\");\n\n            Files.writeString(readme, \"Hello world\\nAgain\");\n            r.add(readme);\n            var second = r.commit(\"Updated readme\", \"duke\", \"duke@openjdk.org\");\n\n            var otherBranch = r.branch(initial, \"other\");\n            r.checkout(otherBranch);\n            var contributing = dir.path().resolve(\"CONTRIBUTING.md\");\n            Files.writeString(contributing, \"Patches welcome!\\n\");\n            r.add(contributing);\n            var otherCommit = r.commit(\"Added contributing\", \"duke\", \"duke@openjdk.org\");\n\n            if (vcs == VCS.HG) {\n                r.checkout(second);\n            } else {\n                r.checkout(r.defaultBranch());\n            }\n            var result = r.cherryPick(otherCommit);\n            assertTrue(result);\n\n            var diff = r.diff(second);\n            assertEquals(1, diff.patches().size());\n            var patch = diff.patches().get(0);\n            assertTrue(patch.status().isAdded());\n            assertEquals(Path.of(\"CONTRIBUTING.md\"), patch.target().path().get());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testFailingCherryPicking(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory(false)) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var readme = dir.path().resolve(\"README.md\");\n            Files.writeString(readme, \"Hello world\\n\");\n            r.add(readme);\n            var initial = r.commit(\"Added readme\", \"duke\", \"duke@openjdk.org\");\n\n            Files.writeString(readme, \"Hello world\\nAgain\");\n            r.add(readme);\n            var second = r.commit(\"Updated readme\", \"duke\", \"duke@openjdk.org\");\n\n            r.checkout(initial);\n            var otherBranch = r.branch(initial, \"other\");\n            r.checkout(otherBranch);\n            Files.writeString(readme, \"Hello world\\nOne more time!\");\n            r.add(readme);\n            var otherCommit = r.commit(\"Modified readme\", \"duke\", \"duke@openjdk.org\");\n\n            if (vcs == VCS.HG) {\n                r.checkout(second);\n            } else {\n                r.checkout(r.defaultBranch());\n            }\n            var result = r.cherryPick(otherCommit);\n            assertFalse(result);\n\n            var diff = r.diff(second);\n            assertEquals(1, diff.patches().size());\n            var patch = diff.patches().get(0);\n            assertTrue(patch.status().isModified());\n            assertEquals(Path.of(\"README.md\"), patch.target().path().get());\n\n            r.revert(second);\n            assertEquals(List.of(), r.diff(second).patches());\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void testRepositoryContains(VCS vcs) throws IOException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var dir = new TemporaryDirectory()) {\n            var r = TestableRepository.init(dir.path(), vcs);\n            assertTrue(r.isClean());\n\n            var readme = dir.path().resolve(\"README.md\");\n            Files.writeString(readme, \"Hello world\\n\");\n            r.add(readme);\n            var hash = r.commit(\"Added readme\", \"duke\", \"duke@openjdk.org\");\n\n            assertTrue(r.contains(hash));\n            assertFalse(r.contains(new Hash(\"0123456789012345678901234567890123456789\")));\n        }\n    }\n\n    @Test\n    void testTagPush() throws IOException {\n        try (var dir = new TemporaryDirectory()) {\n            var upstream = Repository.init(dir.path(), VCS.GIT);\n\n            Files.write(upstream.root().resolve(\".git\").resolve(\"config\"),\n                        List.of(\"[receive]\", \"denyCurrentBranch=ignore\"),\n                        WRITE, APPEND);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            upstream.add(readme);\n            var initialCommit = upstream.commit(\"Add README\", \"duke\", \"duke@openjdk.org\");\n\n            try (var dir2 = new TemporaryDirectory()) {\n                var downstream = Repository.init(dir2.path(), VCS.GIT);\n\n                 // note: forcing unix path separators for URI\n                var upstreamURI = URI.create(\"file:///\" + dir.toString().replace('\\\\', '/'));\n\n                var fetchHead = downstream.fetch(upstreamURI, downstream.defaultBranch().name()).orElseThrow();\n                downstream.checkout(fetchHead, false);\n\n                var downstreamReadme = dir2.path().resolve(\"README\");\n                Files.write(downstreamReadme, List.of(\"Downstream change\"), WRITE, APPEND);\n\n                downstream.add(downstreamReadme);\n                var head = downstream.commit(\"Modify README\", \"duke\", \"duke@openjdk.org\");\n\n                var tag = downstream.tag(initialCommit, \"v1.0\", \"Added tag v1.0\", \"duke\", \"duke@openjdk.org\");\n\n                downstream.push(tag, upstreamURI, false);\n            }\n\n            upstream.checkout(upstream.resolve(upstream.defaultBranch().name()).get(), false);\n\n            var commits = upstream.commits().asList();\n            assertEquals(1, commits.size());\n            var tags = upstream.tags();\n            assertEquals(1, tags.size());\n            assertEquals(\"v1.0\", tags.get(0).name());\n        }\n    }\n\n    @Test\n    void testCommitMetadataWithBranchesWithGit() throws IOException {\n        try (var dir = new TemporaryDirectory()) {\n            var r = Repository.init(dir.path(), VCS.GIT);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n            r.add(readme);\n            var first = r.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n\n            var b1 = r.branch(first, \"b1\");\n            r.checkout(b1);\n            Files.write(readme, List.of(\"One more line\"), WRITE, APPEND);\n            r.add(readme);\n            var second = r.commit(\"Modified README\", \"duke\", \"duke@openjdk.org\");\n\n            r.checkout(r.defaultBranch());\n            var b2 = r.branch(first, \"b2\");\n            r.checkout(b2);\n            Files.write(readme, List.of(\"An additional line\"), WRITE, APPEND);\n            r.add(readme);\n            var third = r.commit(\"Additional line added to README\", \"duke\", \"duke@openjdk.org\");\n\n            var metadata = r.commitMetadataFor(List.of(r.defaultBranch()));\n            assertEquals(1, metadata.size());\n            assertEquals(first, metadata.get(0).hash());\n\n            metadata = r.commitMetadataFor(List.of(r.defaultBranch(), b1));\n            assertEquals(2, metadata.size());\n            assertTrue(metadata.stream().anyMatch(c -> c.hash().equals(first)));\n            assertTrue(metadata.stream().anyMatch(c -> c.hash().equals(second)));\n\n            metadata = r.commitMetadataFor(List.of(r.defaultBranch(), b2));\n            assertEquals(2, metadata.size());\n            assertTrue(metadata.stream().anyMatch(c -> c.hash().equals(first)));\n            assertTrue(metadata.stream().anyMatch(c -> c.hash().equals(third)));\n\n            metadata = r.commitMetadataFor(List.of(r.defaultBranch(), b1, b2));\n            assertEquals(3, metadata.size());\n            assertTrue(metadata.stream().anyMatch(c -> c.hash().equals(first)));\n            assertTrue(metadata.stream().anyMatch(c -> c.hash().equals(second)));\n            assertTrue(metadata.stream().anyMatch(c -> c.hash().equals(third)));\n        }\n    }\n\n    @Test\n    void testNotes() throws IOException, InterruptedException {\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path(), VCS.GIT);\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            repo.add(readme);\n            var head = repo.commit(\"Add README\", \"author\", \"author@openjdk.org\");\n\n            // No notes by default\n            assertEquals(List.of(), repo.notes(head));\n\n            // Add a new note\n            var note = List.of(\"A notice\");\n            repo.addNote(head, note, \"duke\", \"duke@openjdk.org\");\n            assertEquals(note, repo.notes(head));\n        }\n    }\n\n    @Test\n    void testThrowsExceptionOnOverwritingExistingNote() throws IOException, InterruptedException {\n        try (var dir = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(dir.path(), VCS.GIT);\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, readme!\"));\n\n            repo.add(readme);\n            var head = repo.commit(\"Add README\", \"author\", \"author@openjdk.org\");\n\n            // No notes by default\n            assertEquals(List.of(), repo.notes(head));\n\n            // Add a new note\n            var note = List.of(\"A notice\");\n            repo.addNote(head, note, \"duke\", \"duke@openjdk.org\");\n            assertEquals(note, repo.notes(head));\n\n            // Cannot add an additional note\n            assertThrows(IllegalStateException.class, () -> {\n                try {\n                    repo.addNote(head, List.of(\"Another notice\"), \"Duke\", \"duke@openjdk.org\");\n                } catch (IOException e) {\n                    throw new RuntimeException(e);\n                }\n            });\n        }\n    }\n\n    @Test\n    void testCommitCountWithBranchesWithGit() throws IOException {\n        try (var dir = new TemporaryDirectory()) {\n            var r = Repository.init(dir.path(), VCS.GIT);\n\n            var readme = dir.path().resolve(\"README\");\n            Files.write(readme, List.of(\"Hello, world!\"));\n            r.add(readme);\n            var first = r.commit(\"Added README\", \"duke\", \"duke@openjdk.org\");\n\n            var b1 = r.branch(first, \"b1\");\n            r.checkout(b1);\n            Files.write(readme, List.of(\"One more line\"), WRITE, APPEND);\n            r.add(readme);\n            var second = r.commit(\"Modified README\", \"duke\", \"duke@openjdk.org\");\n\n            r.checkout(r.defaultBranch());\n            var b2 = r.branch(first, \"b2\");\n            r.checkout(b2);\n            Files.write(readme, List.of(\"An additional line\"), WRITE, APPEND);\n            r.add(readme);\n            var third = r.commit(\"Additional line added to README\", \"duke\", \"duke@openjdk.org\");\n\n            assertEquals(3, r.commitCount());\n            assertEquals(3, r.commitCount(List.of(new Branch(\"b1\"), new Branch(\"b2\"))));\n            assertEquals(2, r.commitCount(List.of(new Branch(\"b1\"))));\n            assertEquals(1, r.commitCount(List.of(r.defaultBranch())));\n        }\n    }\n}\n"
  },
  {
    "path": "vcs/src/test/java/org/openjdk/skara/vcs/UnifiedDiffParserTests.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs;\n\nimport java.util.List;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class UnifiedDiffParserTests {\n    @Test\n    public void simple() {\n        var diff1 =\n            \"diff --git a/bots/tester/src/test/java/org/openjdk/skara/bots/tester/inmemoryhostedrepository.java b/bots/tester/src/test/java/org/openjdk/skara/bots/tester/inmemoryhostedrepository.java\\n\" +\n            \"index 883d3f51..bff21edd 100644\\n\" +\n            \"--- a/bots/tester/src/test/java/org/openjdk/skara/bots/tester/inmemoryhostedrepository.java\\n\" +\n            \"+++ b/bots/tester/src/test/java/org/openjdk/skara/bots/tester/inmemoryhostedrepository.java\\n\" +\n            \"@@ -169,4 +169,9 @@ public void addcommitcomment(hash commit, string body) {\\n\" +\n            \"     public list<check> allchecks(hash hash) {\\n\" +\n            \"         return list.of();\\n\" +\n            \"     }\\n\" +\n            \"+\\n\" +\n            \"+    @override\\n\" +\n            \"+    public list<commitcomment> recentcommitcomments() {\\n\" +\n            \"+        return list.of();\\n\" +\n            \"+    }\\n\" +\n            \" }\\n\";\n\n        var diff2 =\n            \"diff --git a/forge/src/main/java/org/openjdk/skara/forge/commitcomment.java b/forge/src/main/java/org/openjdk/skara/forge/commitcomment.java\\n\" +\n            \"index 5d9139c9..16eda547 100644\\n\" +\n            \"--- a/forge/src/main/java/org/openjdk/skara/forge/commitcomment.java\\n\" +\n            \"+++ b/forge/src/main/java/org/openjdk/skara/forge/commitcomment.java\\n\" +\n            \"@@ -29,9 +29,11 @@\\n\" +\n            \" import java.nio.file.path;\\n\" +\n            \" import java.time.zoneddatetime;\\n\" +\n            \" import java.util.*;\\n\" +\n            \"+import java.util.function.supplier;\\n\" +\n            \" \\n\" +\n            \" public class commitcomment extends comment {\\n\" +\n            \"-    private final hash commit;\\n\" +\n            \"+    private hash commit;\\n\" +\n            \"+    private final supplier<hash> commitsupplier;\\n\" +\n            \"     private final path path;\\n\" +\n            \"     private final int line;\\n\" +\n            \" \\n\" +\n            \"@@ -39,6 +41,16 @@ public commitcomment(hash commit, path path, int line, string id, string body, h\\n\" +\n            \"         super(id, body, author, createdat, updatedat);\\n\" +\n            \" \\n\" +\n            \"         this.commit = commit;\\n\" +\n            \"+        this.commitsupplier = null;\\n\" +\n            \"+        this.path = path;\\n\" +\n            \"+        this.line = line;\\n\" +\n            \"+    }\\n\" +\n            \"+\\n\" +\n            \"+    public commitcomment(supplier<hash> commitsupplier, path path, int line, string id, string body, hostuser author, zoneddatetime createdat, zoneddatetime updatedat) {\\n\" +\n            \"+        super(id, body, author, createdat, updatedat);\\n\" +\n            \"+\\n\" +\n            \"+        this.commit = null;\\n\" +\n            \"+        this.commitsupplier = commitsupplier;\\n\" +\n            \"         this.path = path;\\n\" +\n            \"         this.line = line;\\n\" +\n            \"     }\\n\" +\n            \"@@ -47,6 +59,9 @@ public commitcomment(hash commit, path path, int line, string id, string body, h\\n\" +\n            \"      * returns the hash of the commit.\\n\" +\n            \"      */\\n\" +\n            \"     public hash commit() {\\n\" +\n            \"+        if (commit == null) {\\n\" +\n            \"+            commit = commitsupplier.get();\\n\" +\n            \"+        }\\n\" +\n            \"         return commit;\\n\" +\n            \"     }\\n\" +\n            \" \\n\";\n\n        var diff3 =\n            \"diff --git a/forge/src/main/java/org/openjdk/skara/forge/hostedrepository.java b/forge/src/main/java/org/openjdk/skara/forge/hostedrepository.java\\n\" +\n            \"index e9f711a1..8a612523 100644\\n\" +\n            \"--- a/forge/src/main/java/org/openjdk/skara/forge/hostedrepository.java\\n\" +\n            \"+++ b/forge/src/main/java/org/openjdk/skara/forge/hostedrepository.java\\n\" +\n            \"@@ -68,6 +68,7 @@ pullrequest createpullrequest(hostedrepository target,\\n\" +\n            \"     hash branchhash(string ref);\\n\" +\n            \"     list<hostedbranch> branches();\\n\" +\n            \"     list<commitcomment> commitcomments(hash hash);\\n\" +\n            \"+    list<commitcomment> recentcommitcomments();\\n\" +\n            \"     void addcommitcomment(hash hash, string body);\\n\" +\n            \"     optional<commitmetadata> commitmetadata(hash hash);\\n\" +\n            \"     list<check> allchecks(hash hash);\\n\";\n\n        var diff4 =\n            \"diff --git a/forge/src/main/java/org/openjdk/skara/forge/github/githubhost.java b/forge/src/main/java/org/openjdk/skara/forge/github/githubhost.java\\n\" +\n            \"index bc11a4b3..2c29a5e9 100644\\n\" +\n            \"--- a/forge/src/main/java/org/openjdk/skara/forge/github/githubhost.java\\n\" +\n            \"+++ b/forge/src/main/java/org/openjdk/skara/forge/github/githubhost.java\\n\" +\n            \"@@ -199,10 +199,21 @@ hostuser parseuserfield(jsonvalue json) {\\n\" +\n            \"     }\\n\" +\n            \" \\n\" +\n            \"     hostuser parseuserobject(jsonvalue json) {\\n\" +\n            \"+        return hostuser(json.get(\\\"id\\\").asint(), json.get(\\\"login\\\").asstring());\\n\" +\n            \"+    }\\n\" +\n            \"+\\n\" +\n            \"+    hostuser hostuser(int id, string username) {\\n\" +\n            \"+        return hostuser.builder()\\n\" +\n            \"+                       .id(id)\\n\" +\n            \"+                       .username(username)\\n\" +\n            \"+                       .supplier(() -> user(username).orelsethrow())\\n\" +\n            \"+                       .build();\\n\" +\n            \"+    }\\n\" +\n            \"+\\n\" +\n            \"+    hostuser hostuser(string username) {\\n\" +\n            \"         return hostuser.builder()\\n\" +\n            \"-                       .id(json.get(\\\"id\\\").asint())\\n\" +\n            \"-                       .username(json.get(\\\"login\\\").asstring())\\n\" +\n            \"-                       .supplier(() -> user(json.get(\\\"login\\\").asstring()).orelsethrow())\\n\" +\n            \"+                       .username(username)\\n\" +\n            \"+                       .supplier(() -> user(username).orelsethrow())\\n\" +\n            \"                        .build();\\n\" +\n            \"     }\\n\" +\n            \" \\n\" +\n            \"@@ -269,10 +280,10 @@ jsonobject runsearch(string category, string query) {\\n\" +\n            \"             return optional.empty();\\n\" +\n            \"         }\\n\" +\n            \" \\n\" +\n            \"-        return optional.of(ashostuser(details.asobject()));\\n\" +\n            \"+        return optional.of(tohostuser(details.asobject()));\\n\" +\n            \"     }\\n\" +\n            \" \\n\" +\n            \"-    private static hostuser ashostuser(jsonobject details) {\\n\" +\n            \"+    private hostuser tohostuser(jsonobject details) {\\n\" +\n            \"         // always present\\n\" +\n            \"         var login = details.get(\\\"login\\\").asstring();\\n\" +\n            \"         var id = details.get(\\\"id\\\").asint();\\n\" +\n            \"@@ -302,7 +313,7 @@ public hostuser currentuser() {\\n\" +\n            \"                 // on windows always return \\\"personalaccesstoken\\\" as username.\\n\" +\n            \"                 // query github for the username instead.\\n\" +\n            \"                 var details = request.get(\\\"user\\\").execute().asobject();\\n\" +\n            \"-                currentuser = ashostuser(details);\\n\" +\n            \"+                currentuser = tohostuser(details);\\n\" +\n            \"             } else {\\n\" +\n            \"                 throw new illegalstateexception(\\\"no credentials present\\\");\\n\" +\n            \"             }\\n\";\n\n        var diff5 =\n            \"diff --git a/forge/src/main/java/org/openjdk/skara/forge/github/githubrepository.java b/forge/src/main/java/org/openjdk/skara/forge/github/githubrepository.java\\n\" +\n            \"index 7198f13d..08769f62 100644\\n\" +\n            \"--- a/forge/src/main/java/org/openjdk/skara/forge/github/githubrepository.java\\n\" +\n            \"+++ b/forge/src/main/java/org/openjdk/skara/forge/github/githubrepository.java\\n\" +\n            \"@@ -268,29 +268,80 @@ public hash branchhash(string ref) {\\n\" +\n            \"                        .collect(collectors.tolist());\\n\" +\n            \"     }\\n\" +\n            \" \\n\" +\n            \"+    private commitcomment tocommitcomment(jsonvalue o) {\\n\" +\n            \"+        var hash = new hash(o.get(\\\"commit_id\\\").asstring());\\n\" +\n            \"+        var line = o.get(\\\"line\\\").isnull()? -1 : o.get(\\\"line\\\").asint();\\n\" +\n            \"+        var path = o.get(\\\"path\\\").isnull()? null : path.of(o.get(\\\"path\\\").asstring());\\n\" +\n            \"+        return new commitcomment(hash,\\n\" +\n            \"+                                 path,\\n\" +\n            \"+                                 line,\\n\" +\n            \"+                                 o.get(\\\"id\\\").tostring(),\\n\" +\n            \"+                                 o.get(\\\"body\\\").asstring(),\\n\" +\n            \"+                                 githubhost.parseuserfield(o),\\n\" +\n            \"+                                 zoneddatetime.parse(o.get(\\\"created_at\\\").asstring()),\\n\" +\n            \"+                                 zoneddatetime.parse(o.get(\\\"updated_at\\\").asstring()));\\n\" +\n            \"+    }\\n\" +\n            \"+\\n\" +\n            \"     @override\\n\" +\n            \"     public list<commitcomment> commitcomments(hash hash) {\\n\" +\n            \"         return request.get(\\\"commits/\\\" + hash.hex() + \\\"/comments\\\")\\n\" +\n            \"                       .execute()\\n\" +\n            \"                       .stream()\\n\" +\n            \"-                      .map(jsonvalue::asobject)\\n\" +\n            \"-                      .map(o -> {\\n\" +\n            \"-                           var line = o.get(\\\"line\\\").isnull()? -1 : o.get(\\\"line\\\").asint();\\n\" +\n            \"-                           var path = o.get(\\\"path\\\").isnull()? null : path.of(o.get(\\\"path\\\").asstring());\\n\" +\n            \"-                           return new commitcomment(hash,\\n\" +\n            \"-                                                    path,\\n\" +\n            \"-                                                    line,\\n\" +\n            \"-                                                    o.get(\\\"id\\\").tostring(),\\n\" +\n            \"-                                                    o.get(\\\"body\\\").asstring(),\\n\" +\n            \"-                                                    githubhost.parseuserfield(o),\\n\" +\n            \"-                                                    zoneddatetime.parse(o.get(\\\"created_at\\\").asstring()),\\n\" +\n            \"-                                                    zoneddatetime.parse(o.get(\\\"updated_at\\\").asstring()));\\n\" +\n            \"-\\n\" +\n            \"-\\n\" +\n            \"-                      })\\n\" +\n            \"+                      .map(this::tocommitcomment)\\n\" +\n            \"                       .collect(collectors.tolist());\\n\" +\n            \"     }\\n\" +\n            \" \\n\" +\n            \"+    @override\\n\" +\n            \"+    public list<commitcomment> recentcommitcomments() {\\n\" +\n            \"+        var parts = name().split(\\\"/\\\");\\n\" +\n            \"+        var owner = parts[0];\\n\" +\n            \"+        var name = parts[1];\\n\" +\n            \"+\\n\" +\n            \"+        var data = githubhost.graphql()\\n\" +\n            \"+                             .post()\\n\" +\n            \"+                             .body(json.object().put(\\\"query\\\", query))\\n\" +\n            \"+                             .execute()\\n\" +\n            \"+                             .get(\\\"data\\\");\\n\" +\n            \"+        return data.get(\\\"repository\\\")\\n\" +\n            \"+                   .get(\\\"commitcomments\\\")\\n\" +\n            \"+                   .get(\\\"nodes\\\")\\n\" +\n            \"+                   .stream()\\n\" +\n            \"+                   .map(o -> {\\n\" +\n            \"+                       var hash = new hash(o.get(\\\"commit\\\").get(\\\"oid\\\").asstring());\\n\" +\n            \"+                       var createdat = zoneddatetime.parse(o.get(\\\"createdat\\\").asstring());\\n\" +\n            \"+                       var updatedat = zoneddatetime.parse(o.get(\\\"updatedat\\\").asstring());\\n\" +\n            \"+                       var id = o.get(\\\"databaseid\\\").asstring();\\n\" +\n            \"+                       var body = o.get(\\\"body\\\").asstring();\\n\" +\n            \"+                       var user = githubhost.hostuser(o.get(\\\"login\\\").asstring());\\n\" +\n            \"+                       return new commitcomment(hash,\\n\" +\n            \"+                                                null,\\n\" +\n            \"+                                                -1,\\n\" +\n            \"+                                                id,\\n\" +\n            \"+                                                body,\\n\" +\n            \"+                                                user,\\n\" +\n            \"+                                                createdat,\\n\" +\n            \"+                                                updatedat);\\n\" +\n            \"+                   })\\n\" +\n            \"+                   .collect(collectors.tolist());\\n\" +\n            \"+    }\\n\" +\n            \"+\\n\" +\n            \"     @override\\n\" +\n            \"     public void addcommitcomment(hash hash, string body) {\\n\" +\n            \"         var query = json.object().put(\\\"body\\\", body);\\n\";\n\n        var diff6 =\n            \"diff --git a/forge/src/main/java/org/openjdk/skara/forge/gitlab/gitlabrepository.java b/forge/src/main/java/org/openjdk/skara/forge/gitlab/gitlabrepository.java\\n\" +\n            \"index 80c78f6c..7bfb45bf 100644\\n\" +\n            \"--- a/forge/src/main/java/org/openjdk/skara/forge/gitlab/gitlabrepository.java\\n\" +\n            \"+++ b/forge/src/main/java/org/openjdk/skara/forge/gitlab/gitlabrepository.java\\n\" +\n            \"@@ -22,6 +22,7 @@\\n\" +\n            \"  */\\n\" +\n            \" package org.openjdk.skara.forge.gitlab;\\n\" +\n            \" \\n\" +\n            \"+import org.openjdk.skara.host.hostuser;\\n\" +\n            \" import org.openjdk.skara.forge.*;\\n\" +\n            \" import org.openjdk.skara.json.*;\\n\" +\n            \" import org.openjdk.skara.network.*;\\n\" +\n            \"@@ -33,6 +34,7 @@\\n\" +\n            \" import java.time.*;\\n\" +\n            \" import java.time.format.datetimeformatter;\\n\" +\n            \" import java.util.*;\\n\" +\n            \"+import java.util.function.supplier;\\n\" +\n            \" import java.util.regex.pattern;\\n\" +\n            \" import java.util.stream.collectors;\\n\" +\n            \" \\n\" +\n            \"@@ -290,27 +292,90 @@ public hash branchhash(string ref) {\\n\" +\n            \"                        .collect(collectors.tolist());\\n\" +\n            \"     }\\n\" +\n            \" \\n\" +\n            \"+    private commitcomment tocommitcomment(hash hash, jsonvalue o) {\\n\" +\n            \"+       var line = o.get(\\\"line\\\").isnull()? -1 : o.get(\\\"line\\\").asint();\\n\" +\n            \"+       var path = o.get(\\\"path\\\").isnull()? null : path.of(o.get(\\\"path\\\").asstring());\\n\" +\n            \"+       // gitlab does not offer updated_at for commit comments\\n\" +\n            \"+       var createdat = zoneddatetime.parse(o.get(\\\"created_at\\\").asstring());\\n\" +\n            \"+       // gitlab does not offer an id for commit comments\\n\" +\n            \"+       var id = \\\"\\\";\\n\" +\n            \"+       return new commitcomment(hash,\\n\" +\n            \"+                                path,\\n\" +\n            \"+                                line,\\n\" +\n            \"+                                id,\\n\" +\n            \"+                                o.get(\\\"note\\\").asstring(),\\n\" +\n            \"+                                gitlabhost.parseauthorfield(o),\\n\" +\n            \"+                                createdat,\\n\" +\n            \"+                                createdat);\\n\" +\n            \"+    }\\n\" +\n            \"+\\n\" +\n            \"     @override\\n\" +\n            \"     public list<commitcomment> commitcomments(hash hash) {\\n\" +\n            \"         return request.get(\\\"repository/commits/\\\" + hash.hex() + \\\"/comments\\\")\\n\" +\n            \"                       .execute()\\n\" +\n            \"                       .stream()\\n\" +\n            \"-                      .map(jsonvalue::asobject)\\n\" +\n            \"+                      .map(o -> tocommitcomment(hash, o))\\n\" +\n            \"+                      .collect(collectors.tolist());\\n\" +\n            \"+    }\\n\" +\n            \"+\\n\" +\n            \"+    private hash commitwithcomment(string committitle,\\n\" +\n            \"+                                   string commentbody,\\n\" +\n            \"+                                   zoneddatetime commentcreatedat,\\n\" +\n            \"+                                   hostuser author) {\\n\" +\n            \"+        var result = request.get(\\\"search\\\")\\n\" +\n            \"+                            .param(\\\"scope\\\", \\\"commits\\\")\\n\" +\n            \"+                            .param(\\\"search\\\", committitle)\\n\" +\n            \"+                            .execute()\\n\" +\n            \"+                            .stream()\\n\" +\n            \"+                            .filter(o -> o.get(\\\"title\\\").asstring().equals(committitle))\\n\" +\n            \"+                            .map(o -> new hash(o.get(\\\"id\\\").asstring()))\\n\" +\n            \"+                            .collect(collectors.tolist());\\n\" +\n            \"+        if (result.isempty()) {\\n\" +\n            \"+            throw new illegalargumentexception(\\\"no commit with title: \\\" + committitle);\\n\" +\n            \"+        }\\n\" +\n            \"+        if (result.size() > 1) {\\n\" +\n            \"+            var filtered = result.stream()\\n\" +\n            \"+                                 .flatmap(hash -> commitcomments(hash).stream()\\n\" +\n            \"+                                                                      .filter(c -> c.body().equals(commentbody))\\n\" +\n            \"+                                                                      .filter(c -> c.createdat().equals(commentcreatedat))\\n\" +\n            \"+                                                                      .filter(c -> c.author().equals(author)))\\n\" +\n            \"+                                 .map(c -> c.commit())\\n\" +\n            \"+                                 .collect(collectors.tolist());\\n\" +\n            \"+            if (filtered.isempty()) {\\n\" +\n            \"+                throw new illegalstateexception(\\\"no commit with title '\\\" + committitle +\\n\" +\n            \"+                                                \\\"' and comment '\\\" + commentbody + \\\"'\\\");\\n\" +\n            \"+            }\\n\" +\n            \"+            if (filtered.size() > 1) {\\n\" +\n            \"+                var hashes = filtered.stream().map(hash::hex).collect(collectors.tolist());\\n\" +\n            \"+                throw new illegalstateexception(\\\"multiple commits with identical comment '\\\" + commentbody + \\\"': \\\"\\n\" +\n            \"+                                                 + string.join(\\\",\\\", hashes));\\n\" +\n            \"+            }\\n\" +\n            \"+            return filtered.get(0);\\n\" +\n            \"+        }\\n\" +\n            \"+        return result.get(0);\\n\" +\n            \"+    }\\n\" +\n            \"+\\n\" +\n            \"+    @override\\n\" +\n            \"+    public list<commitcomment> recentcommitcomments() {\\n\" +\n            \"+        var twodaysago = zoneddatetime.now().minusdays(2);\\n\" +\n            \"+        var formatter = datetimeformatter.ofpattern(\\\"yyyy-mm-dd\\\");\\n\" +\n            \"+        return request.get(\\\"events\\\")\\n\" +\n            \"+                      .param(\\\"after\\\", twodaysago.format(formatter))\\n\" +\n            \"+                      .execute()\\n\" +\n            \"+                      .stream()\\n\" +\n            \"+                      .filter(o -> o.contains(\\\"note\\\") &&\\n\" +\n            \"+                                   o.get(\\\"note\\\").contains(\\\"noteable_type\\\") &&\\n\" +\n            \"+                                   o.get(\\\"note\\\").get(\\\"noteable_type\\\").asstring().equals(\\\"commit\\\"))\\n\" +\n            \"                       .map(o -> {\\n\" +\n            \"-                           var line = o.get(\\\"line\\\").isnull()? -1 : o.get(\\\"line\\\").asint();\\n\" +\n            \"-                           var path = o.get(\\\"path\\\").isnull()? null : path.of(o.get(\\\"path\\\").asstring());\\n\" +\n            \"-                           // gitlab does not offer updated_at for commit comments\\n\" +\n            \"-                           var createdat = zoneddatetime.parse(o.get(\\\"created_at\\\").asstring());\\n\" +\n            \"-                           // gitlab does not offer an id for commit comments\\n\" +\n            \"-                           var id = \\\"\\\";\\n\" +\n            \"-                           return new commitcomment(hash,\\n\" +\n            \"-                                                    path,\\n\" +\n            \"-                                                    line,\\n\" +\n            \"-                                                    id,\\n\" +\n            \"-                                                    o.get(\\\"note\\\").asstring(),\\n\" +\n            \"-                                                    gitlabhost.parseauthorfield(o),\\n\" +\n            \"-                                                    createdat,\\n\" +\n            \"-                                                    createdat);\\n\" +\n            \"+                          var createdat = zoneddatetime.parse(o.get(\\\"note\\\").get(\\\"created_at\\\").asstring());\\n\" +\n            \"+                          var body = o.get(\\\"note\\\").get(\\\"body\\\").asstring();\\n\" +\n            \"+                          var user = gitlabhost.parseauthorfield(o);\\n\" +\n            \"+                          var id = o.get(\\\"note\\\").get(\\\"id\\\").asstring();\\n\" +\n            \"+                          supplier<hash> hash = () -> commitwithcomment(o.get(\\\"target_title\\\").asstring(),\\n\" +\n            \"+                                                                        body,\\n\" +\n            \"+                                                                        at,\\n\" +\n            \"+                                                                        user);\\n\" +\n            \"+                          return new CommitComment(hash, null, -1, id, body, user, at, at);\\n\" +\n            \"                       })\\n\" +\n            \"                       .collect(Collectors.toList());\\n\" +\n            \"     }\\n\";\n\n        var diff7 =\n            \"diff --git a/test/src/main/java/org/openjdk/skara/test/TestHostedRepository.java b/test/src/main/java/org/openjdk/skara/test/TestHostedRepository.java\\n\" +\n            \"index 489f49ef..e777f0f8 100644\\n\" +\n            \"--- a/test/src/main/java/org/openjdk/skara/test/TestHostedRepository.java\\n\" +\n            \"+++ b/test/src/main/java/org/openjdk/skara/test/TestHostedRepository.java\\n\" +\n            \"@@ -211,6 +211,14 @@ public Hash branchHash(String ref) {\\n\" +\n            \"         return commitComments.get(hash);\\n\" +\n            \"     }\\n\" +\n            \" \\n\" +\n            \"+    @Override\\n\" +\n            \"+    public List<CommitComment> recentCommitComments() {\\n\" +\n            \"+        return commitComments.values()\\n\" +\n            \"+                             .stream()\\n\" +\n            \"+                             .flatMap(e -> e.stream())\\n\" +\n            \"+                             .collect(Collectors.toList());\\n\" +\n            \"+    }\\n\" +\n            \"+\\n\" +\n            \"     @Override\\n\" +\n            \"     public void addCommitComment(Hash hash, String body) {\\n\" +\n            \"         var id = nextCommitCommentId;\";\n\n        for (var diff : List.of(diff1, diff2, diff3, diff4, diff5, diff6, diff7)) {\n            var hunks = UnifiedDiffParser.parseSingleFileDiff(diff.split(\"\\n\"));\n            assertFalse(hunks.isEmpty());\n        }\n    }\n\n    @Test\n    public void noNewline() {\n        var diff =\n            \"diff --git a/test/src/main/java/org/openjdk/skara/test/TestHostedRepository.java b/test/src/main/java/org/openjdk/skara/test/TestHostedRepository.java\\n\" +\n            \"index 489f49ef..e777f0f8 100644\\n\" +\n            \"--- a/test/src/main/java/org/openjdk/skara/test/TestHostedRepository.java\\n\" +\n            \"+++ b/test/src/main/java/org/openjdk/skara/test/TestHostedRepository.java\\n\" +\n            \"@@ -211,6 +211,14 @@ public Hash branchHash(String ref) {\\n\" +\n            \"+    static class CustomSelectorProviderImpl extends SelectorProvider {\\n\" +\n            \"+        @Override public DatagramChannel openDatagramChannel() { return null; }\\n\" +\n            \"+        @Override public DatagramChannel openDatagramChannel(ProtocolFamily family) { return null; }\\n\" +\n            \"+        @Override public Pipe openPipe() { return null; }\\n\" +\n            \"+        @Override public AbstractSelector openSelector() { return null; }\\n\" +\n            \"+        @Override public ServerSocketChannel openServerSocketChannel() { return null; }\\n\" +\n            \"+        @Override public SocketChannel openSocketChannel() { return null; }\\n\" +\n            \"+    }\\n\" +\n            \"+}\\n\" +\n            \"\\\\ No newline at end of file\\n\";\n        var hunks = UnifiedDiffParser.parseSingleFileDiff(diff.split(\"\\n\"));\n        assertEquals(1, hunks.size());\n        assertFalse(hunks.get(0).target().hasNewlineAtEndOfFile());\n    }\n\n    @Test\n    public void binaryFile() {\n        var diff =\n            \"diff --git a/file.bin b/file.bin\\n\" +\n            \"new file mode 100644\\n\" +\n            \"index 0000000000000000000000000000000000000000..2020dd2b626d1bcf60351a2be801548eb65c53cd\\n\" +\n            \"Binary files /dev/null and b/file.bin differ\";\n        var hunks = UnifiedDiffParser.parseSingleFileDiff(diff.split(\"\\n\"));\n        assertEquals(List.of(), hunks);\n    }\n}\n"
  },
  {
    "path": "vcs/src/test/java/org/openjdk/skara/vcs/git/GitVersionTest.java",
    "content": "/*\n * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.git;\n\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\n\nimport java.util.stream.Stream;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.junit.jupiter.params.provider.Arguments.arguments;\n\npublic class GitVersionTest {\n\n    static Stream<Arguments> supportedVersions() {\n        return Stream.of(\n            arguments(\"git version 2.22.3\", 2, 22, 3),\n            arguments(\"git version 2.23.2\", 2, 23, 2),\n            arguments(\"git version 2.24.2\", 2, 24, 2),\n            arguments(\"git version 2.25.3\", 2, 25, 3),\n            arguments(\"git version 2.26.1\", 2, 26, 1),\n\n            arguments(\"git version 2.30.0.284\", 2, 30, 0),\n            arguments(\"git version 2.30.0.284.cafebabe-yoyodyne\", 2, 30, 0),\n\n            arguments(\"git version 2.27.0.windows.1\", 2, 27, 0)\n        );\n    }\n\n    @ParameterizedTest\n    @MethodSource(\"supportedVersions\")\n    void testSupportedVersions(String versionsString, int major, int minor, int security) {\n        GitVersion version = GitVersion.parse(versionsString);\n\n        assertEquals(version.major(), major);\n        assertEquals(version.minor(), minor);\n        assertEquals(version.security(), security);\n\n        assertFalse(version.isUnknown());\n        assertTrue(version.isKnownSupported());\n    }\n\n    static Stream<Arguments> unsupportedVersions() {\n        return Stream.of(\n            // check for \"git 10 bug\"\n            arguments(\"git version 10.1.12\", 10, 1, 12),\n\n            arguments(\"git version 2.17.4\", 2, 17, 4),\n            arguments(\"git version 2.18.3\", 2, 18, 3),\n            arguments(\"git version 2.19.4\", 2, 19, 4),\n            arguments(\"git version 2.20.3\", 2, 20, 3),\n            arguments(\"git version 2.21.2\", 2, 21, 2),\n            arguments(\"git version 2.21.1 (Apple Git-122.3) \", 2, 21, 1) // doesn't contain security fix\n        );\n    }\n\n    @ParameterizedTest\n    @MethodSource(\"unsupportedVersions\")\n    void testUnsupportedVersions(String versionsString, int major, int minor, int security) {\n        GitVersion version = GitVersion.parse(versionsString);\n\n        assertEquals(version.major(), major);\n        assertEquals(version.minor(), minor);\n        assertEquals(version.security(), security);\n\n        assertFalse(version.isUnknown());\n        assertFalse(version.isKnownSupported());\n    }\n\n    static Stream<Arguments> unknownVersions() {\n        return Stream.of(\n            arguments(\"asdfxzxcv\")\n        );\n    }\n\n    @ParameterizedTest\n    @MethodSource(\"unknownVersions\")\n    void testUnsupportedVersions(String versionsString) {\n        GitVersion version = GitVersion.parse(versionsString);\n\n        assertTrue(version.isUnknown());\n        assertFalse(version.isKnownSupported());\n    }\n\n}\n"
  },
  {
    "path": "vcs/src/test/java/org/openjdk/skara/vcs/openjdk/CommitMessageBuilderTests.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk;\n\nimport java.util.List;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass CommitMessageBuilderTests {\n    @Test\n    void commitMessageBuilderPlain() {\n        var lines = CommitMessage.title(\"Simple commit\")\n                                 .format(CommitMessageFormatters.v1);\n        assertEquals(List.of(\"Simple commit\"), lines);\n    }\n\n    @Test\n    void commitMessageBuilderReviewers() {\n        var lines = CommitMessage.title(\"Simple commit\")\n                                 .reviewer(\"reviewer1\")\n                                 .reviewer(\"reviewer2\")\n                                 .format(CommitMessageFormatters.v1);\n        assertEquals(List.of(\"Simple commit\", \"\", \"Reviewed-by: reviewer1, reviewer2\"), lines);\n    }\n\n    @Test\n    void commitMessageBuilderIssues() {\n        var issues = List.of(new Issue(\"123\", \"First\"), new Issue(\"456\", \"Second\"));\n        var lines = CommitMessage.title(issues)\n                                 .format(CommitMessageFormatters.v1);\n        assertEquals(List.of(\"123: First\", \"456: Second\"), lines);\n    }\n\n    @Test\n    void commitBuilderNullTitle() {\n        String title = null;\n        assertThrows(IllegalArgumentException.class, () -> CommitMessage.title(title));\n    }\n\n    @Test\n    void commitBuilderEmptyTitle() {\n        assertThrows(IllegalArgumentException.class, () -> CommitMessage.title(\"\"));\n    }\n\n    @Test\n    void commitBuilderBothTitleAndIssue() {\n        assertThrows(IllegalArgumentException.class, () -> CommitMessage.title(\"test\")\n                                                                        .issue(new Issue(\"123\", \"test\"))\n                                                                        .format(CommitMessageFormatters.v1));\n    }\n}\n"
  },
  {
    "path": "vcs/src/test/java/org/openjdk/skara/vcs/openjdk/CommitMessageFormattersTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk;\n\nimport java.util.List;\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nimport org.openjdk.skara.vcs.Author;\nimport org.openjdk.skara.vcs.Hash;\n\npublic class CommitMessageFormattersTests {\n    private CommitMessageFormatter v0() {\n        return CommitMessageFormatters.v0;\n    }\n\n    private CommitMessageFormatter v1() {\n        return CommitMessageFormatters.v1;\n    }\n\n    @Test\n    void formatVersion0WithBugAndReviewer() {\n        var lines = CommitMessage.title(new Issue(\"01234567\", \"A bug\"))\n                                 .reviewer(\"foo\")\n                                 .format(v0());\n        assertEquals(List.of(\"01234567: A bug\",\n                             \"Reviewed-by: foo\"),\n                     lines);\n    }\n\n    @Test\n    void formatVersion0WithBugAndReviewerAndSummary() {\n        var lines = CommitMessage.title(new Issue(\"01234567\", \"A bug\"))\n                                 .summary(\"A summary\")\n                                 .reviewer(\"foo\")\n                                 .format(v0());\n        assertEquals(List.of(\"01234567: A bug\",\n                             \"Summary: A summary\",\n                             \"Reviewed-by: foo\"),\n                     lines);\n    }\n\n    @Test\n    void formatVersion0WithBugAndReviewerAndSummaryAndContributor() {\n        var lines = CommitMessage.title(new Issue(\"01234567\", \"A bug\"))\n                                 .summary(\"A summary\")\n                                 .reviewer(\"foo\")\n                                 .contributor(new Author(\"Baz Bar\", \"baz@bar.org\"))\n                                 .format(v0());\n        assertEquals(List.of(\"01234567: A bug\",\n                             \"Summary: A summary\",\n                             \"Reviewed-by: foo\",\n                             \"Contributed-by: Baz Bar <baz@bar.org>\"),\n                     lines);\n    }\n\n    @Test\n    void formatVersion0WithMultipleContributors() {\n        var lines = CommitMessage.title(new Issue(\"01234567\", \"A bug\"))\n                                 .summary(\"A summary\")\n                                 .reviewer(\"foo\")\n                                 .contributors(new Author(\"Baz Bar\", \"baz@bar.org\"),\n                                               new Author(\"Foo Bar\", \"foo@bar.org\"))\n                                 .format(v0());\n        assertEquals(List.of(\"01234567: A bug\",\n                             \"Summary: A summary\",\n                             \"Reviewed-by: foo\",\n                             \"Contributed-by: Baz Bar <baz@bar.org>, Foo Bar <foo@bar.org>\"),\n                     lines);\n    }\n\n    @Test\n    void formatVersion1WithBugAndReviewer() {\n        var lines = CommitMessage.title(\"01234567: A bug\")\n                                 .reviewer(\"foo\")\n                                 .format(v1());\n        assertEquals(List.of(\"01234567: A bug\",\n                             \"\",\n                             \"Reviewed-by: foo\"),\n                     lines);\n    }\n\n    @Test\n    void formatVersion1WithBugAndReviewerAndSummary() {\n        var lines = CommitMessage.title(\"01234567: A bug\")\n                                 .summary(\"A summary\")\n                                 .reviewer(\"foo\")\n                                 .format(v1());\n        assertEquals(List.of(\"01234567: A bug\",\n                             \"\",\n                             \"A summary\",\n                             \"\",\n                             \"Reviewed-by: foo\"),\n                     lines);\n    }\n\n    @Test\n    void formatVersion1WithBugAndReviewerAndSummaryAndContributor() {\n        var lines = CommitMessage.title(\"01234567: A bug\")\n                                 .summary(\"A summary\")\n                                 .reviewer(\"foo\")\n                                 .contributor(new Author(\"Baz Bar\", \"baz@bar.org\"))\n                                 .format(v1());\n        assertEquals(List.of(\"01234567: A bug\",\n                             \"\",\n                             \"A summary\",\n                             \"\",\n                             \"Co-authored-by: Baz Bar <baz@bar.org>\",\n                             \"Reviewed-by: foo\"),\n                     lines);\n    }\n\n    @Test\n    void formatVersion1WithMultipleContributors() {\n        var lines = CommitMessage.title(\"01234567: A bug\")\n                                 .summary(\"A summary\")\n                                 .reviewer(\"foo\")\n                                 .contributors(new Author(\"Baz Bar\", \"baz@bar.org\"),\n                                               new Author(\"Foo Bar\", \"foo@bar.org\"))\n                                 .format(v1());\n        assertEquals(List.of(\"01234567: A bug\",\n                             \"\",\n                             \"A summary\",\n                             \"\",\n                             \"Co-authored-by: Baz Bar <baz@bar.org>\",\n                             \"Co-authored-by: Foo Bar <foo@bar.org>\",\n                             \"Reviewed-by: foo\"),\n                     lines);\n    }\n\n    @Test\n    void formatVersion1WithOriginal() {\n        var lines = CommitMessage.title(\"01234567: A bug\")\n                                 .summary(\"A summary\")\n                                 .reviewer(\"foo\")\n                                 .contributors(new Author(\"Baz Bar\", \"baz@bar.org\"))\n                                 .original(new Hash(\"0123456789012345678901234567890123456789\"))\n                                 .format(v1());\n        assertEquals(List.of(\"01234567: A bug\",\n                             \"\",\n                             \"A summary\",\n                             \"\",\n                             \"Co-authored-by: Baz Bar <baz@bar.org>\",\n                             \"Reviewed-by: foo\",\n                             \"Backport-of: 0123456789012345678901234567890123456789\"),\n                     lines);\n    }\n}\n"
  },
  {
    "path": "vcs/src/test/java/org/openjdk/skara/vcs/openjdk/CommitMessageParsersTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.vcs.Author;\nimport org.openjdk.skara.vcs.Hash;\n\nimport java.util.List;\nimport java.util.Optional;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\npublic class CommitMessageParsersTests {\n    @Test\n    void parseVersion0Commit() {\n        var text = List.of(\"01234567: A bug\",\n                           \"Reviewed-by: foo\",\n                           \"Contributed-by: Bar O'Baz <bar.obaz@localhost.com>\");\n        var message = CommitMessageParsers.v0.parse(text);\n\n        assertEquals(List.of(new Issue(\"01234567\", \"A bug\")), message.issues());\n        assertEquals(List.of(new Author(\"Bar O'Baz\", \"bar.obaz@localhost.com\")),\n                     message.contributors());\n        assertEquals(List.of(\"foo\"), message.reviewers());\n        assertEquals(List.of(), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n    }\n\n    @Test\n    void parseVersion0CommitWithExtraNewline() {\n        var text = List.of(\"01234567: A bug\",\n                           \"\",\n                           \"Summary: summary\",\n                           \"Reviewed-by: foo\");\n        var message = CommitMessageParsers.v0.parse(text);\n\n        assertEquals(List.of(new Issue(\"01234567\", \"A bug\")), message.issues());\n        assertEquals(List.of(), message.contributors());\n        assertEquals(List.of(), message.reviewers());\n        assertEquals(List.of(), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(\"\", \"Summary: summary\", \"Reviewed-by: foo\"), message.additional());\n    }\n\n    @Test\n    void parseVersion0CommitWithSummary() {\n        var text = List.of(\"01234567: A bug\",\n                           \"Summary: This is a summary\",\n                           \"Reviewed-by: foo\",\n                           \"Contributed-by: Bar O'Baz <bar.obaz@localhost.com>\");\n        var message = CommitMessageParsers.v0.parse(text);\n\n        assertEquals(List.of(new Issue(\"01234567\", \"A bug\")), message.issues());\n        assertEquals(List.of(new Author(\"Bar O'Baz\", \"bar.obaz@localhost.com\")),\n                     message.contributors());\n        assertEquals(List.of(\"foo\"), message.reviewers());\n        assertEquals(List.of(\"This is a summary\"), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n    }\n\n\n    @Test\n    void parseVersion1Commit() {\n        var text = List.of(\"01234567: A bug\",\n                           \"\",\n                           \"Co-authored-by: Bar O'Baz <bar.obaz@localhost.com>\",\n                           \"Reviewed-by: foo\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(List.of(new Issue(\"01234567\", \"A bug\")), message.issues());\n        assertEquals(List.of(new Author(\"Bar O'Baz\", \"bar.obaz@localhost.com\")),\n                     message.contributors());\n        assertEquals(List.of(\"foo\"), message.reviewers());\n        assertEquals(List.of(), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n    }\n\n    @Test\n    void parseVersion1CommitWithSummary() {\n        var text = List.of(\"01234567: A bug\",\n                           \"\",\n                           \"This is a summary\",\n                           \"\",\n                           \"Co-authored-by: Bar O'Baz <bar.obaz@localhost.com>\",\n                           \"Reviewed-by: foo\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(List.of(new Issue(\"01234567\", \"A bug\")), message.issues());\n        assertEquals(List.of(new Author(\"Bar O'Baz\", \"bar.obaz@localhost.com\")),\n                     message.contributors());\n        assertEquals(List.of(\"foo\"), message.reviewers());\n        assertEquals(List.of(\"This is a summary\"), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n    }\n\n    @Test\n    void parseVersion1CommitWithMultiPargraphSummary() {\n        var text = List.of(\"01234567: A bug\",\n                           \"\",\n                           \"This is a summary\",\n                           \"\",\n                           \"This is another summary paragraph\",\n                           \"\",\n                           \"Co-authored-by: Bar O'Baz <bar.obaz@localhost.com>\",\n                           \"Reviewed-by: foo\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(List.of(new Issue(\"01234567\", \"A bug\")), message.issues());\n        assertEquals(List.of(new Author(\"Bar O'Baz\", \"bar.obaz@localhost.com\")),\n                     message.contributors());\n        assertEquals(List.of(\"foo\"), message.reviewers());\n        assertEquals(List.of(\"This is a summary\",\"\",\"This is another summary paragraph\"),\n                     message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n    }\n\n    @Test\n    void parseVersion1CommitWithoutTrailers() {\n        var text = List.of(\"01234567: A bug\",\n                           \"\",\n                           \"This is a summary\",\n                           \"\",\n                           \"This is another summary paragraph\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(List.of(new Issue(\"01234567\", \"A bug\")), message.issues());\n        assertEquals(List.of(), message.contributors());\n        assertEquals(List.of(), message.reviewers());\n        assertEquals(List.of(\"This is a summary\",\"\",\"This is another summary paragraph\"),\n                     message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n    }\n\n    @Test\n    void parseVersion1CommitWithoutIssue() {\n        var text = List.of(\"Bugfix!\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"Bugfix!\", message.title());\n        assertEquals(List.of(), message.issues());\n        assertEquals(List.of(), message.contributors());\n        assertEquals(List.of(), message.reviewers());\n        assertEquals(List.of(), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n    }\n\n    @Test\n    void parseVersion1CommitWithTitleAndSummaryAndTrailers() {\n        var text = List.of(\"Bugfix!\",\n                           \"\",\n                           \"This is a summary\",\n                           \"\",\n                           \"Co-authored-by: Baz Bar <baz@bar.org>\",\n                           \"Reviewed-by: foo\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"Bugfix!\", message.title());\n        assertEquals(List.of(), message.issues());\n        assertEquals(List.of(new Author(\"Baz Bar\", \"baz@bar.org\")), message.contributors());\n        assertEquals(List.of(\"foo\"), message.reviewers());\n        assertEquals(List.of(\"This is a summary\"), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n    }\n\n    @Test\n    void parseVersion1CommitWithIssueAndReview() {\n        var text = List.of(\"01234567: An issue\",\n                           \"\",\n                           \"Reviewed-by: foo\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"01234567: An issue\", message.title());\n        assertEquals(List.of(new Issue(\"01234567\", \"An issue\")), message.issues());\n        assertEquals(List.of(), message.contributors());\n        assertEquals(List.of(\"foo\"), message.reviewers());\n        assertEquals(List.of(), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n    }\n\n    @Test\n    void parseVersion1CommitWithIssueAndInvalidReviewLine() {\n        var text = List.of(\"01234567: An issue\",\n                \"Reviewed-by: foo\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"01234567: An issue\", message.title());\n        assertEquals(List.of(new Issue(\"01234567\", \"An issue\")), message.issues());\n        assertEquals(List.of(), message.contributors());\n        assertEquals(List.of(), message.reviewers());\n        assertEquals(List.of(), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(\"Reviewed-by: foo\"), message.additional());\n    }\n\n    @Test\n    void internationalCoAuthors() {\n        var text = List.of(\"01234567: An issue\",\n                           \"\",\n                           \"Co-authored-by: Föö Bår <foo@bar.com>\",\n                           \"Co-authored-by: Bår Bäz <bar@baz.com>\",\n                           \"Reviewed-by: ab, cd, ef\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"01234567: An issue\", message.title());\n        assertEquals(List.of(new Issue(\"01234567\", \"An issue\")), message.issues());\n        assertEquals(List.of(\"ab\", \"cd\", \"ef\"), message.reviewers());\n        assertEquals(List.of(new Author(\"Föö Bår\", \"foo@bar.com\"), new Author(\"Bår Bäz\", \"bar@baz.com\")),\n                     message.contributors());\n        assertEquals(List.of(), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n    }\n\n    @Test\n    void capitalLetterInEmail() {\n        var text = List.of(\"01234567: An issue\",\n                           \"\",\n                           \"Co-authored-by: Just An Example <JustAn@example.com>\",\n                           \"Reviewed-by: ab, cd, ef\");\n\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"01234567: An issue\", message.title());\n        assertEquals(List.of(new Issue(\"01234567\", \"An issue\")), message.issues());\n        assertEquals(List.of(\"ab\", \"cd\", \"ef\"), message.reviewers());\n        assertEquals(List.of(new Author(\"Just An Example\", \"JustAn@example.com\")),\n                     message.contributors());\n        assertEquals(List.of(), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n    }\n\n    @Test\n    void backportOfTrailer() {\n        var text = List.of(\"01234567: An issue\",\n                           \"\",\n                           \"Reviewed-by: ab\",\n                           \"Backport-of: 0123456789012345678901234567890123456789\");\n\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"01234567: An issue\", message.title());\n        assertEquals(List.of(new Issue(\"01234567\", \"An issue\")), message.issues());\n        assertEquals(List.of(\"ab\"), message.reviewers());\n        assertEquals(List.of(), message.contributors());\n        assertEquals(List.of(), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n        assertEquals(Optional.of(new Hash(\"0123456789012345678901234567890123456789\")), message.original());\n    }\n\n    @Test\n    void onlyBackportOfTrailer() {\n        var text = List.of(\"01234567: An issue\",\n                           \"\",\n                           \"Backport-of: 0123456789012345678901234567890123456789\");\n\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"01234567: An issue\", message.title());\n        assertEquals(List.of(new Issue(\"01234567\", \"An issue\")), message.issues());\n        assertEquals(List.of(), message.reviewers());\n        assertEquals(List.of(), message.contributors());\n        assertEquals(List.of(), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n        assertEquals(Optional.of(new Hash(\"0123456789012345678901234567890123456789\")), message.original());\n    }\n\n    @Test\n    void invalidBackportOfTrailer() {\n        var text = List.of(\"01234567: An issue\",\n                \"\",\n                \"Backport-of: not-a-hash\");\n\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"01234567: An issue\", message.title());\n        assertEquals(List.of(new Issue(\"01234567\", \"An issue\")), message.issues());\n        assertEquals(List.of(), message.reviewers());\n        assertEquals(List.of(), message.contributors());\n        assertEquals(List.of(), message.summaries());\n        assertEquals(List.of(new CommitMessage.CustomTrailer(\"Backport-of\", \"not-a-hash\")), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n        assertEquals(Optional.empty(), message.original());\n    }\n\n    @Test\n    void parseVersion1TrailingBlankLine() {\n        var text = List.of(\"01234567: An issue\",\n                           \"\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"01234567: An issue\", message.title());\n        assertEquals(List.of(new Issue(\"01234567\", \"An issue\")), message.issues());\n        assertEquals(List.of(), message.contributors());\n        assertEquals(List.of(), message.reviewers());\n        assertEquals(List.of(), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(\"\"), message.additional());\n    }\n\n    @Test\n    void parseVersion1CustomTrailer() {\n        var text = List.of(\"01234567: An issue\",\n                \"\",\n                \"My-Custom-Trailer: my custom value\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"01234567: An issue\", message.title());\n        assertEquals(List.of(new Issue(\"01234567\", \"An issue\")), message.issues());\n        assertEquals(List.of(), message.contributors());\n        assertEquals(List.of(), message.reviewers());\n        assertEquals(List.of(), message.summaries());\n        assertEquals(List.of(new CommitMessage.CustomTrailer(\"My-Custom-Trailer\", \"my custom value\")), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n    }\n\n    @Test\n    void parseVersion1CustomTrailerWhitespaceLine() {\n        var text = List.of(\"01234567: An issue\",\n                \" \",\n                \"My-Custom-Trailer: my custom value\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"01234567: An issue\", message.title());\n        assertEquals(List.of(new Issue(\"01234567\", \"An issue\")), message.issues());\n        assertEquals(List.of(), message.contributors());\n        assertEquals(List.of(), message.reviewers());\n        assertEquals(List.of(), message.summaries());\n        assertEquals(List.of(new CommitMessage.CustomTrailer(\"My-Custom-Trailer\", \"my custom value\")), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n    }\n\n    @Test\n    void parseVersion1BadCustomTrailerNotLast() {\n        var text = List.of(\"01234567: An issue\",\n                \"\",\n                \"My-Custom-Trailer: my custom value\",\n                \"\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"01234567: An issue\", message.title());\n        assertEquals(List.of(new Issue(\"01234567\", \"An issue\")), message.issues());\n        assertEquals(List.of(), message.contributors());\n        assertEquals(List.of(), message.reviewers());\n        assertEquals(List.of(\"My-Custom-Trailer: my custom value\"), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(\"\"), message.additional());\n    }\n\n    @Test\n    void parseVersion1CustomTrailerWithSummary() {\n        var text = List.of(\"01234567: An issue\",\n                \"\",\n                \"Note: this is text\",\n                \"\",\n                \"My-Custom-Trailer: my custom value\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"01234567: An issue\", message.title());\n        assertEquals(List.of(new Issue(\"01234567\", \"An issue\")), message.issues());\n        assertEquals(List.of(), message.contributors());\n        assertEquals(List.of(), message.reviewers());\n        assertEquals(List.of(\"Note: this is text\"), message.summaries());\n        assertEquals(List.of(new CommitMessage.CustomTrailer(\"My-Custom-Trailer\", \"my custom value\")), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n    }\n\n    @Test\n    void parseVersion1NoTrailer() {\n        var text = List.of(\"01234567: An issue\",\n                \"\",\n                \"Note: this is text\",\n                \"\",\n                \"Not-My-Custom-Trailer: this just text\",\n                \"\",\n                \"More text\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"01234567: An issue\", message.title());\n        assertEquals(List.of(new Issue(\"01234567\", \"An issue\")), message.issues());\n        assertEquals(List.of(), message.contributors());\n        assertEquals(List.of(), message.reviewers());\n        assertEquals(List.of(\"Note: this is text\", \"\", \"Not-My-Custom-Trailer: this just text\", \"\", \"More text\"), message.summaries());\n        assertEquals(List.of(), message.customTrailers());\n        assertEquals(List.of(), message.additional());\n    }\n\n    @Test\n    void parseVersion1CustomTrailersAndMore() {\n        var text = List.of(\"01234567: An issue\",\n                \"\",\n                \"Summary: my summary\",\n                \"\",\n                \"My-Custom-Trailer: my custom value\",\n                \"Reviewed-by: ab\",\n                \"Backport-of: 0123456789012345678901234567890123456789\",\n                \"Other-Custom-Trailer2: another value\");\n        var message = CommitMessageParsers.v1.parse(text);\n\n        assertEquals(\"01234567: An issue\", message.title());\n        assertEquals(List.of(new Issue(\"01234567\", \"An issue\")), message.issues());\n        assertEquals(List.of(), message.contributors());\n        assertEquals(List.of(\"ab\"), message.reviewers());\n        assertEquals(List.of(\"Summary: my summary\"), message.summaries());\n        assertEquals(List.of(new CommitMessage.CustomTrailer(\"My-Custom-Trailer\", \"my custom value\"),\n                new CommitMessage.CustomTrailer(\"Other-Custom-Trailer2\", \"another value\")), message.customTrailers());\n        assertEquals(Optional.of(new Hash(\"0123456789012345678901234567890123456789\")), message.original());\n        assertEquals(List.of(), message.additional());\n    }\n}\n"
  },
  {
    "path": "vcs/src/test/java/org/openjdk/skara/vcs/openjdk/IssueTests.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\npublic class IssueTests {\n    @Test\n    void parseRelaxed() {\n        assertEquals(\"Description\", Issue.fromStringRelaxed(\"1234: Description\").orElseThrow().description());\n        assertEquals(\"Description\", Issue.fromStringRelaxed(\"1234 Description\").orElseThrow().description());\n        assertEquals(\"Description\", Issue.fromStringRelaxed(\"1234   Description\").orElseThrow().description());\n        assertEquals(\"Description\", Issue.fromStringRelaxed(\"1234 - Description\").orElseThrow().description());\n        assertEquals(\"Description\", Issue.fromStringRelaxed(\"1234   -  Description\").orElseThrow().description());\n        assertEquals(\"-Description\", Issue.fromStringRelaxed(\"1234   -Description\").orElseThrow().description());\n        assertEquals(\"-Description\", Issue.fromStringRelaxed(\"1234 - -Description\").orElseThrow().description());\n        assertEquals(\"[Description]\", Issue.fromStringRelaxed(\"1234: [Description]\").orElseThrow().description());\n        assertEquals(\"[Description]\", Issue.fromStringRelaxed(\"1234 [Description]\").orElseThrow().description());\n        assertEquals(\"[Description]\", Issue.fromStringRelaxed(\"1234 - [Description]\").orElseThrow().description());\n    }\n}\n"
  },
  {
    "path": "vcs/src/test/java/org/openjdk/skara/vcs/openjdk/OpenJDKTagTests.java",
    "content": "/*\n * Copyright (c) 2019, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk;\n\nimport org.openjdk.skara.vcs.Tag;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass OpenJDKTagTests {\n    @Test\n    void parseTags() {\n        var tag = new Tag(\"jdk-10+20\");\n        var jdkTag = OpenJDKTag.create(tag).orElseThrow();\n        assertEquals(20, jdkTag.buildNum().orElseThrow());\n        var previousTag = jdkTag.previous().orElseThrow();\n        assertEquals(19, previousTag.buildNum().orElseThrow());\n    }\n\n    @Test\n    void parseSingleDigitTags() {\n        var tag = new Tag(\"jdk-10+10\");\n        var jdkTag = OpenJDKTag.create(tag).orElseThrow();\n        assertEquals(10, jdkTag.buildNum().orElseThrow());\n        var previousTag = jdkTag.previous().orElseThrow();\n        assertEquals(\"jdk-10+9\", previousTag.tag().name());\n        assertEquals(9, previousTag.buildNum().orElseThrow());\n    }\n\n    @Test\n    void parseLegacyTags() {\n        var tag = new Tag(\"jdk7-b147\");\n        var jdkTag = OpenJDKTag.create(tag).orElseThrow();\n        assertEquals(147, jdkTag.buildNum().orElseThrow());\n        var previousTag = jdkTag.previous().orElseThrow();\n        assertEquals(146, previousTag.buildNum().orElseThrow());\n    }\n\n    @Test\n    void parseSingleDigitLegacyTags() {\n        var tag = new Tag(\"jdk7-b10\");\n        var jdkTag = OpenJDKTag.create(tag).orElseThrow();\n        assertEquals(10, jdkTag.buildNum().orElseThrow());\n        var previousTag = jdkTag.previous().orElseThrow();\n        assertEquals(\"jdk7-b09\", previousTag.tag().name());\n        assertEquals(9, previousTag.buildNum().orElseThrow());\n    }\n\n    @Test\n    void parseHotspotTags() {\n        var tag = new Tag(\"hs23.6-b19\");\n        var jdkTag = OpenJDKTag.create(tag).orElseThrow();\n        assertEquals(19, jdkTag.buildNum().orElseThrow());\n        var previousTag = jdkTag.previous().orElseThrow();\n        assertEquals(18, previousTag.buildNum().orElseThrow());\n    }\n\n    @Test\n    void parseSingleDigitHotspotTags() {\n        var tag = new Tag(\"hs23.6-b10\");\n        var jdkTag = OpenJDKTag.create(tag).orElseThrow();\n        assertEquals(10, jdkTag.buildNum().orElseThrow());\n        var previousTag = jdkTag.previous().orElseThrow();\n        assertEquals(\"hs23.6-b09\", previousTag.tag().name());\n        assertEquals(9, previousTag.buildNum().orElseThrow());\n    }\n\n    @Test\n    void noPrevious() {\n        var tag = new Tag(\"jdk-10+0\");\n        var jdkTag = OpenJDKTag.create(tag).orElseThrow();\n        assertEquals(0, jdkTag.buildNum().orElseThrow());\n        assertFalse(jdkTag.previous().isPresent());\n    }\n\n    @Test\n    void parseJfxTags() {\n        var tag = new Tag(\"12.1.3+14\");\n        var jdkTag = OpenJDKTag.create(tag).orElseThrow();\n        assertEquals(\"12.1.3\", jdkTag.version());\n        assertEquals(14, jdkTag.buildNum().orElseThrow());\n        var previousTag = jdkTag.previous().orElseThrow();\n        assertEquals(13, previousTag.buildNum().orElseThrow());\n    }\n\n    @Test\n    void parseJfxTagsGa() {\n        var tag = new Tag(\"12.1-ga\");\n        var jdkTag = OpenJDKTag.create(tag).orElseThrow();\n        assertEquals(\"12.1\", jdkTag.version());\n        assertTrue(jdkTag.buildNum().isEmpty());\n    }\n\n    @Test\n    void parseLegacyJfxTags() {\n        var tag = new Tag(\"8u321-b03\");\n        var jfxTag = OpenJDKTag.create(tag).orElseThrow();\n        assertEquals(\"8u321\", jfxTag.version());\n        assertEquals(3, jfxTag.buildNum().orElseThrow());\n    }\n\n    @Test\n    void parse3DigitVersion() {\n        var tag = new Tag(\"jdk-11.0.15+1\");\n        var jdkTag = OpenJDKTag.create(tag).orElseThrow();\n        assertEquals(\"11.0.15\", jdkTag.version());\n        assertEquals(1, jdkTag.buildNum().orElseThrow());\n    }\n\n    @Test\n    void parse5DigitVersion() {\n        var tag = new Tag(\"jdk-11.0.15.0.3+1\");\n        var jdkTag = OpenJDKTag.create(tag).orElseThrow();\n        assertEquals(\"11.0.15.0.3\", jdkTag.version());\n        assertEquals(1, jdkTag.buildNum().orElseThrow());\n    }\n\n    @Test\n    void parse7DigitVersion() {\n        var tag = new Tag(\"jdk-11.0.15.0.3.4.5+1\");\n        var jdkTag = OpenJDKTag.create(tag).orElseThrow();\n        assertEquals(\"11.0.15.0.3.4.5\", jdkTag.version());\n        assertEquals(1, jdkTag.buildNum().orElseThrow());\n    }\n\n    @Test\n    void parse8uSuffixVersion() {\n        var tag = new Tag(\"jdk8u341-foo-b17\");\n        var jdkTag = OpenJDKTag.create(tag).orElseThrow();\n        assertEquals(\"8u341-foo\", jdkTag.version());\n        assertEquals(17, jdkTag.buildNum().orElseThrow());\n    }\n\n    @Test\n    void parse8uPrefixVersion() {\n        var tag = new Tag(\"shenandoah8u332-b01\");\n        var jdkTag = OpenJDKTag.create(tag).orElseThrow();\n        assertEquals(\"shenandoah8u332\", jdkTag.version());\n        assertEquals(1, jdkTag.buildNum().orElseThrow());\n    }\n}\n"
  },
  {
    "path": "vcs/src/test/java/org/openjdk/skara/vcs/openjdk/converter/GitToHgConverterTests.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk.converter;\n\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestableRepository;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.convert.GitToHgConverter;\nimport org.openjdk.skara.vcs.openjdk.convert.Mark;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Disabled;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.StandardOpenOption;\nimport java.util.*;\nimport java.net.URI;\nimport java.util.stream.Collectors;\nimport java.time.ZonedDateTime;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.junit.jupiter.api.Assumptions.assumeTrue;\n\nclass GitToHgConverterTests {\n\n    private static boolean hgAvailable = true;\n\n    @BeforeAll\n    static void checkHgAvailability() {\n        try {\n            var pb = new ProcessBuilder(\"hg\", \"--version\");\n            pb.redirectErrorStream(true);\n            var process = pb.start();\n            process.waitFor();\n            hgAvailable = (process.exitValue() == 0);\n        } catch (Exception e) {\n            hgAvailable = false;\n        }\n    }\n\n    @BeforeEach\n    void assumeHgAvailable() {\n        assumeTrue(hgAvailable);\n    }\n\n    void assertCommitEquals(ReadOnlyRepository gitRepo, Commit gitCommit, ReadOnlyRepository hgRepo, Commit hgCommit) throws IOException {\n        assertEquals(gitCommit.authored(), hgCommit.authored());\n        assertEquals(gitCommit.isInitialCommit(), hgCommit.isInitialCommit());\n        assertEquals(gitCommit.isMerge(), hgCommit.isMerge());\n        assertEquals(gitCommit.numParents(), hgCommit.numParents());\n\n        var gitFiles = gitRepo.files(gitCommit.hash());\n        var gitFileToHash = new HashMap<Path, Hash>();\n        for (var entry : gitFiles) {\n            gitFileToHash.put(entry.path(), entry.hash());\n        }\n\n        var hgFiles = hgRepo.files(hgCommit.hash());\n        var hgFileToHash = new HashMap<Path, Hash>();\n        for (var entry : hgFiles) {\n            hgFileToHash.put(entry.path(), entry.hash());\n        }\n\n        var hgtags = Path.of(\".hgtags\");\n        assertEquals(gitFiles.size(), hgFiles.size());\n        for (var entry : gitFiles) {\n            var path = entry.path();\n            if (path.equals(hgtags)) {\n                continue;\n            }\n            var gitHash = gitFileToHash.get(path);\n            var hgHash = hgFileToHash.get(path);\n            assertEquals(gitHash, hgHash, \"filename: \" + path);\n        }\n    }\n\n    void assertReposEquals(List<Mark> marks, ReadOnlyRepository gitRepo, ReadOnlyRepository hgRepo) throws IOException {\n        var gitTagNames = gitRepo.tags().stream().map(Tag::name).collect(Collectors.toSet());\n        gitTagNames.add(\"tip\"); // hg always has \"tip\" tag\n        var hgTagNames = hgRepo.tags().stream().map(Tag::name).collect(Collectors.toSet());\n        assertEquals(gitTagNames, hgTagNames);\n\n        var gitCommits = gitRepo.commits(\"master\").asList();\n\n        var gitHashes = new HashSet<Hash>();\n        for (var commit : gitCommits) {\n            gitHashes.add(commit.hash());\n        }\n        for (var mark : marks) {\n            gitHashes.remove(mark.git());\n        }\n        assertEquals(Set.of(), gitHashes);\n\n        var hgCommits = hgRepo.commits().asList();\n        assertTrue(hgCommits.size() >= gitCommits.size(), hgCommits.size() + \" < \" + gitCommits.size());\n        assertEquals(gitCommits.size(), marks.size());\n\n        var gitHashToCommit = new HashMap<Hash, Commit>();\n        for (var commit : gitCommits) {\n            gitHashToCommit.put(commit.hash(), commit);\n        }\n        var hgHashToCommit = new HashMap<Hash, Commit>();\n        for (var commit : hgCommits) {\n            hgHashToCommit.put(commit.hash(), commit);\n        }\n        for (var mark : marks) {\n            assertCommitEquals(gitRepo, gitHashToCommit.get(mark.git()), hgRepo, hgHashToCommit.get(mark.hg()));\n        }\n    }\n\n    @Test\n    void convertOneCommit() throws IOException {\n        try (var hgRoot = new TemporaryDirectory();\n             var gitRoot = new TemporaryDirectory()) {\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n            var readme = gitRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"Hello, world\");\n            gitRepo.add(readme);\n            gitRepo.commit(\"1234567: Added README\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var converter = new GitToHgConverter();\n            var marks = converter.convert(gitRepo, hgRepo);\n\n            var gitCommits = gitRepo.commits().asList();\n            assertEquals(1, gitCommits.size());\n            var gitCommit = gitCommits.get(0);\n\n            var hgCommits = hgRepo.commits().asList();\n            assertEquals(1, hgCommits.size());\n            var hgCommit = hgCommits.get(0);\n\n            assertEquals(hgCommit.author(), new Author(\"foo\", null));\n            assertEquals(hgCommit.message(), gitCommit.message());\n            assertTrue(hgCommit.isInitialCommit());\n\n            assertReposEquals(marks, gitRepo, hgRepo);\n        }\n    }\n\n    @Test\n    void convertOneSponsoredCommit() throws IOException {\n        try (var hgRoot = new TemporaryDirectory();\n             var gitRoot = new TemporaryDirectory()) {\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n            var readme = gitRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"Hello, world\");\n            gitRepo.add(readme);\n            gitRepo.commit(\"1234567: Added README\", \"Foo Bar\", \"foo@host.com\",\n                                                    \"Baz Bar\", \"baz@openjdk.org\");\n\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var converter = new GitToHgConverter();\n            var marks = converter.convert(gitRepo, hgRepo);\n\n            var hgCommits = hgRepo.commits().asList();\n            assertEquals(1, hgCommits.size());\n            var hgCommit = hgCommits.get(0);\n\n            assertEquals(new Author(\"baz\", null), hgCommit.author());\n            assertEquals(List.of(\"1234567: Added README\", \"Contributed-by: Foo Bar <foo@host.com>\"),\n                         hgCommit.message());\n            assertReposEquals(marks, gitRepo, hgRepo);\n        }\n    }\n\n    @Test\n    void convertRepoWithCopy() throws IOException {\n        try (var hgRoot = new TemporaryDirectory();\n             var gitRoot = new TemporaryDirectory()) {\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n            var readme = gitRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"Hello, world\");\n            gitRepo.add(readme);\n            gitRepo.commit(\"Added README\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            var readme2 = gitRoot.path().resolve(\"README2.md\");\n            gitRepo.copy(readme, readme2);\n            gitRepo.commit(\"Copied README\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var converter = new GitToHgConverter();\n            var marks = converter.convert(gitRepo, hgRepo);\n\n            var hgCommits = hgRepo.commits().asList();\n            assertEquals(2, hgCommits.size());\n\n            var hgCopyCommit = hgCommits.get(0);\n            assertEquals(List.of(\"Copied README\"), hgCopyCommit.message());\n            assertFalse(hgCopyCommit.isMerge());\n            var hgCopyDiff = hgCopyCommit.parentDiffs().get(0);\n            assertEquals(1, hgCopyDiff.patches().size());\n            var hgCopyPatch = hgCopyDiff.patches().get(0);\n            assertTrue(hgCopyPatch.status().isCopied());\n            assertTrue(hgCopyPatch.isEmpty());\n\n            assertReposEquals(marks, gitRepo, hgRepo);\n        }\n    }\n\n    @Test\n    void convertRepoWithMove() throws IOException {\n        try (var hgRoot = new TemporaryDirectory();\n             var gitRoot = new TemporaryDirectory()) {\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n            var readme = gitRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"Hello, world\");\n            gitRepo.add(readme);\n            gitRepo.commit(\"Added README\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            var readme2 = gitRoot.path().resolve(\"README2.md\");\n            gitRepo.move(readme, readme2);\n            gitRepo.commit(\"Moved README\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var converter = new GitToHgConverter();\n            var marks = converter.convert(gitRepo, hgRepo);\n\n            var hgCommits = hgRepo.commits().asList();\n            assertEquals(2, hgCommits.size());\n\n            var hgMoveCommit = hgCommits.get(0);\n            assertEquals(List.of(\"Moved README\"), hgMoveCommit.message());\n            assertFalse(hgMoveCommit.isMerge());\n            var hgMoveDiff = hgMoveCommit.parentDiffs().get(0);\n            assertEquals(1, hgMoveDiff.patches().size());\n            var hgMovePatch = hgMoveDiff.patches().get(0);\n            assertTrue(hgMovePatch.status().isRenamed());\n            assertTrue(hgMovePatch.isEmpty());\n\n            assertReposEquals(marks, gitRepo, hgRepo);\n        }\n    }\n\n    @Test\n    void convertOneCoAuthoredCommit() throws IOException {\n        try (var hgRoot = new TemporaryDirectory();\n             var gitRoot = new TemporaryDirectory()) {\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n            var readme = gitRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"Hello, world\");\n            gitRepo.add(readme);\n            var message = List.of(\"1234567: Added README\", \"\", \"Co-authored-by: Baz Bar <baz@openjdk.org>\");\n            gitRepo.commit(String.join(\"\\n\", message), \"Foo Bar\", \"foo@host.com\",\n                                                       \"Baz Bar\", \"baz@openjdk.org\");\n\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var converter = new GitToHgConverter();\n            var marks = converter.convert(gitRepo, hgRepo);\n\n            var hgCommits = hgRepo.commits().asList();\n            assertEquals(1, hgCommits.size());\n            var hgCommit = hgCommits.get(0);\n\n            assertEquals(new Author(\"baz\", null), hgCommit.author());\n            assertEquals(List.of(\"1234567: Added README\", \"Contributed-by: Foo Bar <foo@host.com>, Baz Bar <baz@openjdk.org>\"),\n                         hgCommit.message());\n            assertReposEquals(marks, gitRepo, hgRepo);\n        }\n    }\n\n    @Test\n    void convertCommitWithSummary() throws IOException {\n        try (var hgRoot = new TemporaryDirectory();\n             var gitRoot = new TemporaryDirectory()) {\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n            var readme = gitRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"Hello, world\");\n            gitRepo.add(readme);\n            var message = List.of(\"1234567: Added README\",\n                                  \"\",\n                                  \"Additional text\",\n                                  \"\",\n                                  \"Co-authored-by: Baz Bar <baz@openjdk.org>\");\n            gitRepo.commit(String.join(\"\\n\", message), \"Foo Bar\", \"foo@host.com\",\n                                                       \"Baz Bar\", \"baz@openjdk.org\");\n\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var converter = new GitToHgConverter();\n            var marks = converter.convert(gitRepo, hgRepo);\n\n            var hgCommits = hgRepo.commits().asList();\n            assertEquals(1, hgCommits.size());\n            var hgCommit = hgCommits.get(0);\n\n            assertEquals(new Author(\"baz\", null), hgCommit.author());\n            assertEquals(List.of(\"1234567: Added README\",\n                                 \"Summary: Additional text\",\n                                 \"Contributed-by: Foo Bar <foo@host.com>, Baz Bar <baz@openjdk.org>\"),\n                         hgCommit.message());\n            assertReposEquals(marks, gitRepo, hgRepo);\n        }\n    }\n\n    @Test\n    void convertMergeCommit() throws IOException {\n        try (var hgRoot = new TemporaryDirectory();\n             var gitRoot = new TemporaryDirectory()) {\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n            var readme = gitRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"First line\");\n            gitRepo.add(readme);\n            gitRepo.commit(\"First line\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            Files.writeString(readme, \"Second line\", StandardOpenOption.APPEND);\n            gitRepo.add(readme);\n            var second = gitRepo.commit(\"Second line\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            Files.writeString(readme, \"Third line\", StandardOpenOption.APPEND);\n            gitRepo.add(readme);\n            var third = gitRepo.commit(\"Third line\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            gitRepo.checkout(second, false);\n\n            var contributing = gitRoot.path().resolve(\"CONTRIBUTING.md\");\n            Files.writeString(contributing, \"Contribute\");\n            gitRepo.add(contributing);\n            var toMerge = gitRepo.commit(\"Contributing\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            gitRepo.checkout(gitRepo.defaultBranch(), false);\n            gitRepo.merge(toMerge);\n            gitRepo.commit(\"Merge\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var converter = new GitToHgConverter();\n            var marks = converter.convert(gitRepo, hgRepo);\n            assertReposEquals(marks, gitRepo, hgRepo);\n        }\n    }\n\n    @Test\n    void convertMergeCommitWithP0Diff() throws IOException {\n        try (var hgRoot = new TemporaryDirectory();\n             var gitRoot = new TemporaryDirectory()) {\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n            var readme = gitRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"First line\\n\");\n            gitRepo.add(readme);\n            gitRepo.commit(\"First line\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            Files.writeString(readme, \"Second line\", StandardOpenOption.APPEND);\n            gitRepo.add(readme);\n            var second = gitRepo.commit(\"Second line\\n\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            Files.writeString(readme, \"Third line\\n\", StandardOpenOption.APPEND);\n            gitRepo.add(readme);\n            var third = gitRepo.commit(\"Third line\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            gitRepo.checkout(second, false);\n\n            var contributing = gitRoot.path().resolve(\"CONTRIBUTING.md\");\n            Files.writeString(contributing, \"Contribute\\n\");\n            gitRepo.add(contributing);\n            var toMerge = gitRepo.commit(\"Contributing\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            gitRepo.checkout(gitRepo.defaultBranch(), false);\n            gitRepo.merge(toMerge);\n            Files.writeString(readme, \"Fourth line\\n\", StandardOpenOption.APPEND);\n            gitRepo.add(readme);\n            gitRepo.commit(\"Merge\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var converter = new GitToHgConverter();\n            var marks = converter.convert(gitRepo, hgRepo);\n            assertReposEquals(marks, gitRepo, hgRepo);\n        }\n    }\n\n    @Test\n    void convertMergeCommitWithP1Diff() throws IOException {\n        try (var hgRoot = new TemporaryDirectory();\n             var gitRoot = new TemporaryDirectory()) {\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n            var readme = gitRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"First line\\n\");\n            gitRepo.add(readme);\n            gitRepo.commit(\"First line\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            Files.writeString(readme, \"Second line\\n\", StandardOpenOption.APPEND);\n            gitRepo.add(readme);\n            var second = gitRepo.commit(\"Second line\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            Files.writeString(readme, \"Third line\\n\", StandardOpenOption.APPEND);\n            gitRepo.add(readme);\n            var third = gitRepo.commit(\"Third line\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            gitRepo.checkout(second, false);\n\n            var contributing = gitRoot.path().resolve(\"CONTRIBUTING.md\");\n            Files.writeString(contributing, \"Contribute\\n\");\n            gitRepo.add(contributing);\n            var toMerge = gitRepo.commit(\"Contributing\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            gitRepo.checkout(gitRepo.defaultBranch(), false);\n            gitRepo.merge(toMerge);\n            Files.writeString(contributing, \"More contributions\\n\", StandardOpenOption.APPEND);\n            gitRepo.add(contributing);\n            gitRepo.commit(\"Merge\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var converter = new GitToHgConverter();\n            var marks = converter.convert(gitRepo, hgRepo);\n            assertReposEquals(marks, gitRepo, hgRepo);\n        }\n    }\n\n    /**\n     * Test converting a merge commit where the first parent is an ancestor of the second parent\n     */\n    @Test\n    void convertMergeOfDescendant() throws IOException {\n        try (var hgRoot = new TemporaryDirectory();\n             var gitRoot = new TemporaryDirectory()) {\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n            var readme = gitRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"First line\");\n            gitRepo.add(readme);\n            var first = gitRepo.commit(\"First line\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            var otherBranch = gitRepo.branch(first, \"other\");\n            gitRepo.checkout(otherBranch);\n\n            Files.writeString(readme, \"Second line\\n\", StandardOpenOption.APPEND);\n            gitRepo.add(readme);\n            gitRepo.commit(\"Second line on other branch\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            gitRepo.checkout(gitRepo.defaultBranch(), false);\n\n            gitRepo.merge(otherBranch, Repository.FastForward.DISABLE);\n            gitRepo.commit(\"Merge\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var converter = new GitToHgConverter();\n            var marks = converter.convert(gitRepo, hgRepo);\n            assertReposEquals(marks, gitRepo, hgRepo);\n        }\n    }\n\n    /**\n     * Test converting a merge commit where the second parent is an ancestor of the first parent\n     */\n    @Test\n    void convertMergeOfAncestor() throws IOException {\n        try (var hgRoot = new TemporaryDirectory();\n             var gitRoot = new TemporaryDirectory()) {\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n            var readme = gitRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"First line\");\n            gitRepo.add(readme);\n            var first = gitRepo.commit(\"First line\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            var otherBranch = gitRepo.branch(first, \"other\");\n            gitRepo.checkout(otherBranch);\n\n            Files.writeString(readme, \"Second line\\n\", StandardOpenOption.APPEND);\n            gitRepo.add(readme);\n            var otherBranchHead = gitRepo.commit(\"Second line on other branch\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            var merge = gitRepo.commit(\"Merge\", \"Foo Bar\", \"foo@openjdk.org\", null, \"Foo Bar\", \"\" +\n                    \"foo@openjdk.org\", null, List.of(otherBranchHead, first), gitRepo.tree(otherBranchHead));\n            gitRepo.checkout(gitRepo.defaultBranch());\n            gitRepo.reset(merge, true);\n\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var converter = new GitToHgConverter();\n            var marks = converter.convert(gitRepo, hgRepo);\n            assertReposEquals(marks, gitRepo, hgRepo);\n        }\n    }\n\n    private void cloneAndConvertAndVerify(String repo) throws IOException {\n        try (var hgRoot = new TemporaryDirectory(false);\n             var gitRoot = new TemporaryDirectory(false)) {\n            var gitRepo = Repository.clone(URI.create(\"https://git.openjdk.org/\" + repo + \".git\"), gitRoot.path());\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var converter = new GitToHgConverter(new Branch(\"master\"));\n            var marks = converter.convert(gitRepo, hgRepo);\n            assertReposEquals(marks, gitRepo, hgRepo);\n        }\n    }\n\n    @Test\n    void convertGitTag() throws IOException {\n        try (var hgRoot = new TemporaryDirectory(false);\n             var gitRoot = new TemporaryDirectory(false)) {\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n            var readme = gitRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"First line\\n\");\n            gitRepo.add(readme);\n            gitRepo.commit(\"First line\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            Files.writeString(readme, \"Second line\\n\", StandardOpenOption.APPEND);\n            gitRepo.add(readme);\n            var second = gitRepo.commit(\"Second line\", \"Foo Bar\", \"foo@openjdk.org\");\n            var tagDate = ZonedDateTime.parse(\"2020-08-24T11:30:32+02:00\");\n            var tag = gitRepo.tag(second, \"1.0\", \"Added tag 1.0\", \"Foo Bar\", \"foo@openjdk.org\", tagDate);\n\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var converter = new GitToHgConverter();\n            var marks = converter.convert(gitRepo, hgRepo);\n            var lastMark = marks.getLast();\n            assertEquals(second, lastMark.git());\n            assertTrue(lastMark.tag().isPresent());\n\n            Files.writeString(readme, \"Third line\\n\");\n            gitRepo.add(readme);\n            gitRepo.commit(\"Third line\", \"Foo Bar\", \"foo@openjdk.org\");\n\n            converter = new GitToHgConverter();\n            var newMarks = converter.convert(gitRepo, hgRepo, marks);\n            var hgCommits = hgRepo.commitMetadata(true);\n            assertEquals(4, hgCommits.size());\n            assertEquals(List.of(\"First line\"), hgCommits.get(0).message());\n            assertEquals(List.of(\"Second line\"), hgCommits.get(1).message());\n            assertEquals(List.of(\"Added tag 1.0\"), hgCommits.get(2).message());\n            assertEquals(List.of(\"Third line\"), hgCommits.get(3).message());\n            assertEquals(List.of(new Tag(\"tip\"), new Tag(\"1.0\")), hgRepo.tags());\n\n            var annotated = hgRepo.annotate(new Tag(\"1.0\"));\n            assertTrue(annotated.isPresent());\n            assertEquals(\"foo\", annotated.get().author().name());\n            assertEquals(tagDate, annotated.get().date());\n            assertEquals(\"Added tag 1.0\", annotated.get().message());\n        }\n    }\n\n    @Disabled(\"Depends on internet connection\")\n    @Test\n    void convertDefpath() throws IOException {\n        cloneAndConvertAndVerify(\"defpath\");\n    }\n\n    @Disabled(\"Depends on internet connection\")\n    @Test\n    void convertTrees() throws IOException {\n        cloneAndConvertAndVerify(\"trees\");\n    }\n\n    @Disabled(\"Depends on internet connection\")\n    @Test\n    void convertWebrev() throws IOException {\n        cloneAndConvertAndVerify(\"webrev\");\n    }\n\n    @Disabled(\"Depends on internet connection\")\n    @Test\n    void convertAsmtools() throws IOException {\n        cloneAndConvertAndVerify(\"asmtools\");\n    }\n\n    @Disabled(\"Depends on internet connection\")\n    @Test\n    void convertJcov() throws IOException {\n        cloneAndConvertAndVerify(\"jcov\");\n    }\n\n    @Disabled(\"Depends on internet connection\")\n    @Test\n    void convertJtharness() throws IOException {\n        cloneAndConvertAndVerify(\"jtharness\");\n    }\n\n    @Disabled(\"Depends on internet connection\")\n    @Test\n    void convertJtreg() throws IOException {\n        cloneAndConvertAndVerify(\"jtreg\");\n    }\n\n    @Disabled(\"Depends on internet connection\")\n    @Test\n    void convertJmc() throws IOException {\n        cloneAndConvertAndVerify(\"jmc\");\n    }\n}\n"
  },
  {
    "path": "vcs/src/test/java/org/openjdk/skara/vcs/openjdk/converter/HgToGitConverterTests.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.vcs.openjdk.converter;\n\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestableRepository;\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.convert.HgToGitConverter;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.util.*;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assumptions.assumeTrue;\n\nclass HgToGitConverterTests {\n\n    private static boolean hgAvailable = true;\n\n    @BeforeAll\n    static void checkHgAvailability() {\n        try {\n            var pb = new ProcessBuilder(\"hg\", \"--version\");\n            pb.redirectErrorStream(true);\n            var process = pb.start();\n            process.waitFor();\n            hgAvailable = (process.exitValue() == 0);\n        } catch (Exception e) {\n            hgAvailable = false;\n        }\n    }\n\n    @BeforeEach\n    void assumeHgAvailable() {\n        assumeTrue(hgAvailable);\n    }\n\n    @Test\n    void convertOneCommit() throws IOException {\n        try (var hgRoot = new TemporaryDirectory();\n             var gitRoot = new TemporaryDirectory()) {\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var readme = hgRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"Hello, world\");\n            hgRepo.add(readme);\n            hgRepo.commit(\"1234567: Added README\", \"foo\", \"foo@localhost\");\n\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n\n            var converter = new HgToGitConverter(Map.of(), Map.of(), Set.of(), Set.of(),\n                                                 Map.of(\"foo\", \"Foo Bar <foo@openjdk.org>\"), Map.of(), Map.of());\n            var marks = converter.convert(hgRepo, gitRepo);\n            assertEquals(1, marks.size());\n\n            var gitCommits = gitRepo.commits().asList();\n            assertEquals(1, gitCommits.size());\n            var gitCommit = gitCommits.get(0);\n\n            var hgCommits = hgRepo.commits().asList();\n            assertEquals(1, hgCommits.size());\n            var hgCommit = hgCommits.get(0);\n\n            assertEquals(gitCommit.author(), new Author(\"Foo Bar\", \"foo@openjdk.org\"));\n            assertEquals(gitCommit.committer(), new Author(\"Foo Bar\", \"foo@openjdk.org\"));\n            assertEquals(hgCommit.message(), gitCommit.message());\n            assertEquals(hgCommit.authored(), gitCommit.authored());\n            assertEquals(hgCommit.isInitialCommit(), gitCommit.isInitialCommit());\n            assertEquals(hgCommit.isMerge(), gitCommit.isMerge());\n            assertEquals(hgCommit.numParents(), gitCommit.numParents());\n\n            var hgDiffs = hgCommit.parentDiffs();\n            assertEquals(1, hgDiffs.size());\n            var hgDiff = hgDiffs.get(0);\n\n            var gitDiffs = gitCommit.parentDiffs();\n            assertEquals(1, gitDiffs.size());\n            var gitDiff = gitDiffs.get(0);\n\n            var hgPatches = hgDiff.patches();\n            assertEquals(1, hgPatches.size());\n            var hgPatch = hgPatches.get(0).asTextualPatch();\n\n            var gitPatches = gitDiff.patches();\n            assertEquals(1, gitPatches.size());\n            var gitPatch = gitPatches.get(0).asTextualPatch();\n            assertEquals(hgPatch.stats(), gitPatch.stats());\n\n            assertEquals(hgPatch.source().path(), gitPatch.source().path());\n            assertEquals(hgPatch.source().type(), gitPatch.source().type());\n\n            assertEquals(hgPatch.target().path(), gitPatch.target().path());\n            assertEquals(hgPatch.target().type(), gitPatch.target().type());\n\n            assertEquals(hgPatch.status(), gitPatch.status());\n\n            var hgHunks = hgPatch.hunks();\n            assertEquals(1, hgHunks.size());\n            var hgHunk = hgHunks.get(0);\n\n            var gitHunks = gitPatch.hunks();\n            assertEquals(1, gitHunks.size());\n            var gitHunk = gitHunks.get(0);\n\n            assertEquals(hgHunk.source().range(), gitHunk.source().range());\n            assertEquals(hgHunk.source().lines(), gitHunk.source().lines());\n\n            assertEquals(hgHunk.target().range(), gitHunk.target().range());\n            assertEquals(hgHunk.target().lines(), gitHunk.target().lines());\n\n            var hgStats = hgHunk.stats();\n            var gitStats = gitHunk.stats();\n            assertEquals(hgStats.added(), gitStats.added());\n            assertEquals(hgStats.removed(), gitStats.removed());\n            assertEquals(hgStats.modified(), gitStats.modified());\n        }\n    }\n\n    @Test\n    void convertOneSponsoredCommit() throws IOException {\n        try (var hgRoot = new TemporaryDirectory();\n             var gitRoot = new TemporaryDirectory()) {\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var readme = hgRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"Hello, world\");\n            hgRepo.add(readme);\n            var message = List.of(\"1234567: Added README\", \"Contributed-by: baz@domain.org\");\n            hgRepo.commit(String.join(\"\\n\", message), \"foo\", \"foo@host.com\");\n\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n\n            var converter = new HgToGitConverter(Map.of(), Map.of(), Set.of(), Set.of(),\n                                                 Map.of(\"foo\", \"Foo Bar <foo@openjdk.org>\"),\n                                                 Map.of(\"baz@domain.org\", \"Baz Bar <baz@domain.org>\"),\n                                                 Map.of(\"foo\", List.of(\"foo@host.com\")));\n            var marks = converter.convert(hgRepo, gitRepo);\n            assertEquals(1, marks.size());\n\n            var gitCommits = gitRepo.commits().asList();\n            assertEquals(1, gitCommits.size());\n            var gitCommit = gitCommits.get(0);\n\n            var hgCommits = hgRepo.commits().asList();\n            assertEquals(1, hgCommits.size());\n            var hgCommit = hgCommits.get(0);\n\n            assertEquals(new Author(\"Baz Bar\", \"baz@domain.org\"), gitCommit.author());\n            assertEquals(new Author(\"Foo Bar\", \"foo@openjdk.org\"), gitCommit.committer());\n            assertEquals(List.of(\"1234567: Added README\"), gitCommit.message());\n        }\n    }\n\n    @Test\n    void convertOneCoAuthoredCommit() throws IOException {\n        try (var hgRoot = new TemporaryDirectory();\n             var gitRoot = new TemporaryDirectory()) {\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var readme = hgRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"Hello, world\");\n            hgRepo.add(readme);\n            var message = List.of(\"1234567: Added README\", \"Contributed-by: baz@domain.org, foo@host.com\");\n            hgRepo.commit(String.join(\"\\n\", message), \"foo\", \"foo@host.com\");\n\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n\n            var converter = new HgToGitConverter(Map.of(), Map.of(), Set.of(), Set.of(),\n                                                 Map.of(\"foo\", \"Foo Bar <foo@openjdk.org>\"),\n                                                 Map.of(\"baz@domain.org\", \"Baz Bar <baz@domain.org>\",\n                                                        \"foo@host.com\", \"Foo Bar <foo@host.com>\"),\n                                                 Map.of(\"foo\", List.of(\"foo@host.com\")));\n            var marks = converter.convert(hgRepo, gitRepo);\n            assertEquals(1, marks.size());\n\n            var gitCommits = gitRepo.commits().asList();\n            assertEquals(1, gitCommits.size());\n            var gitCommit = gitCommits.get(0);\n\n            var hgCommits = hgRepo.commits().asList();\n            assertEquals(1, hgCommits.size());\n            var hgCommit = hgCommits.get(0);\n\n            assertEquals(new Author(\"Foo Bar\", \"foo@openjdk.org\"), gitCommit.author());\n            assertEquals(new Author(\"Foo Bar\", \"foo@openjdk.org\"), gitCommit.committer());\n            assertEquals(List.of(\"1234567: Added README\", \"\", \"Co-authored-by: Baz Bar <baz@domain.org>\"),\n                         gitCommit.message());\n        }\n    }\n\n    @Test\n    void convertCommitWithSummary() throws IOException {\n        try (var hgRoot = new TemporaryDirectory();\n             var gitRoot = new TemporaryDirectory()) {\n            var hgRepo = TestableRepository.init(hgRoot.path(), VCS.HG);\n            var readme = hgRoot.path().resolve(\"README.md\");\n\n            Files.writeString(readme, \"Hello, world\");\n            hgRepo.add(readme);\n            var message = List.of(\"1234567: Added README\", \"Summary: additional text\", \"Contributed-by: baz@domain.org, foo@host.com\");\n            hgRepo.commit(String.join(\"\\n\", message), \"foo\", \"foo@host.com\");\n\n            var gitRepo = TestableRepository.init(gitRoot.path(), VCS.GIT);\n\n            var converter = new HgToGitConverter(Map.of(), Map.of(), Set.of(), Set.of(),\n                                                 Map.of(\"foo\", \"Foo Bar <foo@openjdk.org>\"),\n                                                 Map.of(\"baz@domain.org\", \"Baz Bar <baz@domain.org>\",\n                                                        \"foo@host.com\", \"Foo Bar <foo@host.com>\"),\n                                                 Map.of(\"foo\", List.of(\"foo@host.com\")));\n            var marks = converter.convert(hgRepo, gitRepo);\n            assertEquals(1, marks.size());\n\n            var gitCommits = gitRepo.commits().asList();\n            assertEquals(1, gitCommits.size());\n            var gitCommit = gitCommits.get(0);\n\n            var hgCommits = hgRepo.commits().asList();\n            assertEquals(1, hgCommits.size());\n            var hgCommit = hgCommits.get(0);\n\n            assertEquals(new Author(\"Foo Bar\", \"foo@openjdk.org\"), gitCommit.author());\n            assertEquals(new Author(\"Foo Bar\", \"foo@openjdk.org\"), gitCommit.committer());\n            assertEquals(List.of(\"1234567: Added README\", \"\", \"Additional text\", \"\", \"Co-authored-by: Baz Bar <baz@domain.org>\"),\n                         gitCommit.message());\n        }\n    }\n}\n"
  },
  {
    "path": "version/build.gradle",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.version'\n}\n\njar {\n    manifest {\n        attributes(\"Implementation-Title\": \"org.openjdk.skara.version\", \"Implementation-Version\": archiveVersion)\n    }\n}\n\npublishing {\n    publications {\n        version(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "version/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.version {\n    exports org.openjdk.skara.version;\n}\n"
  },
  {
    "path": "version/src/main/java/org/openjdk/skara/version/Version.java",
    "content": "/*\n * Copyright (c) 2019, 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.version;\n\nimport java.io.IOException;\nimport java.util.jar.Manifest;\nimport java.util.Optional;\n\npublic class Version {\n    private static String version = null;\n    public static Optional<String> fromManifest() {\n        if (version == null) {\n            try {\n                var resources = Version.class.getClassLoader().getResources(\"META-INF/MANIFEST.MF\");\n                while (resources.hasMoreElements()) {\n                    var manifest = new Manifest(resources.nextElement().openStream());\n                    var title = manifest.getMainAttributes().getValue(\"Implementation-Title\");\n                    if (title != null && title.equals(Version.class.getModule().getName())) {\n                        version = manifest.getMainAttributes().getValue(\"Implementation-Version\");\n                        break;\n                    }\n                }\n            } catch (IOException e) {\n                // pass\n            }\n        }\n\n        return Optional.ofNullable(version);\n    }\n}\n"
  },
  {
    "path": "webrev/build.gradle",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.webrev'\n    test {\n        requires 'org.junit.jupiter.api'\n        requires 'org.junit.jupiter.params'\n        requires 'org.openjdk.skara.test'\n        opens 'org.openjdk.skara.webrev' to 'org.junit.platform.commons'\n    }\n}\n\ndependencies {\n    implementation project(':vcs')\n    implementation project(':json')\n\n    testImplementation project(':test')\n}\n\ntask copyResources(type: Copy) {\n    from \"${projectDir}/src/main/resources\"\n    into \"${buildDir}/classes/java/test\"\n}\n\ntest {\n    dependsOn 'copyResources'\n}\n\npublishing {\n    publications {\n        webrev(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.webrev {\n    requires org.openjdk.skara.vcs;\n    requires org.openjdk.skara.json;\n    requires java.net.http;\n    requires java.logging;\n\n    exports org.openjdk.skara.webrev;\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/AddedFileView.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.charset.MalformedInputException;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\nclass AddedFileView implements FileView {\n    private final Patch patch;\n    private final Path out;\n    private final List<CommitMetadata> commits;\n    private final MetadataFormatter formatter;\n    private final List<String> newContent;\n    private final byte[] binaryContent;\n    private final Stats stats;\n\n    public AddedFileView(ReadOnlyRepository repo, Hash base, Hash head, List<CommitMetadata> commits, MetadataFormatter formatter, Patch patch, Path out) throws IOException {\n        this.patch = patch;\n        this.out = out;\n        this.commits = commits;\n        this.formatter = formatter;\n        var path = patch.target().path().get();\n        var pathInRepo = repo.root().resolve(path);\n        if (patch.isTextual()) {\n            binaryContent = null;\n            if (head == null) {\n                List<String> lines = null;\n                for (var charset : List.of(StandardCharsets.UTF_8, StandardCharsets.ISO_8859_1)) {\n                    try {\n                        lines = Files.readAllLines(pathInRepo, charset);\n                        break;\n                    } catch (MalformedInputException e) {\n                        continue;\n                    }\n                }\n                if (lines == null) {\n                    throw new IllegalStateException(\"Could not read \" + pathInRepo + \" as UTF-8 nor as ISO-8859-1\");\n                }\n                newContent = lines;\n            } else {\n                newContent = repo.lines(path, head).orElseThrow(IllegalArgumentException::new);\n            }\n            stats = new Stats(patch.asTextualPatch().stats(), newContent.size());\n        } else {\n            newContent = null;\n            if (head == null) {\n                binaryContent = Files.readAllBytes(pathInRepo);\n            } else {\n                binaryContent = repo.show(path, head).orElseThrow(IllegalArgumentException::new);\n            }\n            stats = Stats.empty();\n        }\n    }\n\n    @Override\n    public Stats stats() {\n        return stats;\n    }\n\n\n    @Override\n    public void render(Writer w) throws IOException {\n        w.write(\"<p>\\n\");\n        w.write(\"  <code>\\n\");\n        if (patch.isTextual()) {\n            w.write(\"------ ------ ------ ------ --- \");\n\n            var newView = new NewView(out, patch.target().path().get(), newContent);\n            newView.render(w);\n\n            var addedPatchView = new AddedPatchView(out, patch.target().path().get(), patch.asTextualPatch());\n            addedPatchView.render(w);\n\n            var rawView = new RawView(out, patch.target().path().get(), newContent);\n            rawView.render(w);\n        } else {\n            w.write(\"------ ------ ------ ------ --- --- \");\n\n            var addedPatchView = new AddedPatchView(out, patch.target().path().get(), patch.asBinaryPatch());\n            addedPatchView.render(w);\n\n            var rawView = new RawView(out, patch.target().path().get(), binaryContent);\n            rawView.render(w);\n        }\n        w.write(\"  </code>\\n\");\n        w.write(\"  <span class=\\\"file-added\\\">\");\n        w.write(patch.target().path().get().toString());\n        w.write(\"</span>\");\n\n        if (patch.target().type().get().isVCSLink()) {\n            w.write(\" <i>(submodule)</i>\\n\");\n        } else if (patch.isBinary()) {\n            w.write(\" <i>(binary)</i>\\n\");\n        } else {\n            w.write(\"\\n\");\n        }\n\n        w.write(\"<p>\\n\");\n\n        if (patch.isTextual()) {\n            w.write(\"<blockquote>\\n\");\n            if (!commits.isEmpty()) {\n                w.write(\"  <pre>\\n\");\n                w.write(commits.stream()\n                        .map(formatter::format)\n                        .collect(Collectors.joining(\"\\n\")));\n                w.write(\"  </pre>\\n\");\n            }\n            w.write(\"  <span class=\\\"stat\\\">\\n\");\n            w.write(stats.toString());\n            w.write(\"  </span>\");\n            w.write(\"</blockquote>\\n\");\n        }\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/AddedPatchView.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.openjdk.skara.vcs.TextualPatch;\nimport org.openjdk.skara.vcs.BinaryPatch;\n\nimport java.io.*;\nimport java.nio.file.*;\n\nclass AddedPatchView implements View {\n    private final Path out;\n    private final Path file;\n    private final TextualPatch textualPatch;\n    private final BinaryPatch binaryPatch;\n\n    public AddedPatchView(Path out, Path file, TextualPatch patch) {\n        this.out = out;\n        this.file = file;\n        this.textualPatch = patch;\n        this.binaryPatch = null;\n    }\n\n    public AddedPatchView(Path out, Path file, BinaryPatch patch) {\n        this.out = out;\n        this.file = file;\n        this.textualPatch = null;\n        this.binaryPatch = patch;\n    }\n\n    @Override\n    public void render(Writer w) throws IOException {\n        var patchFile = out.resolve(file.toString() + \".patch\");\n        Files.createDirectories(patchFile.getParent());\n\n        if (binaryPatch != null) {\n            renderBinary(patchFile);\n        } else {\n            renderTextual(patchFile);\n        }\n\n        w.write(\"<a href=\\\"\");\n        w.write(Webrev.relativeToIndex(out, patchFile));\n        w.write(\"\\\">Patch</a>\\n\");\n    }\n\n    private void renderBinary(Path patchFile) throws IOException {\n        try (var fw = Files.newBufferedWriter(patchFile)) {\n            var targetPath = ViewUtils.pathWithUnixSeps(binaryPatch.target().path().get());\n            fw.write(\"diff a/\");\n            fw.write(targetPath);\n            fw.write(\" b/\");\n            fw.write(targetPath);\n            fw.write(\"\\n\");\n            fw.write(\"Binary files /dev/null and \");\n            fw.write(targetPath);\n            fw.write(\" differ\\n\");\n        }\n    }\n\n    private void renderTextual(Path patchFile) throws IOException {\n        try (var fw = Files.newBufferedWriter(patchFile)) {\n            fw.write(\"diff a/\");\n            fw.write(ViewUtils.pathWithUnixSeps(textualPatch.target().path().get()));\n            fw.write(\" b/\");\n            fw.write(ViewUtils.pathWithUnixSeps(textualPatch.target().path().get()));\n            fw.write(\"\\n\");\n            fw.write(\"--- /dev/null\");\n            fw.write(\"\\n\");\n            fw.write(\"+++ b/\");\n            fw.write(ViewUtils.pathWithUnixSeps(textualPatch.target().path().get()));\n            fw.write(\"\\n\");\n\n            var hunks = textualPatch.hunks();\n            if (hunks.size() == 1) {\n                var hunk = hunks.get(0);\n\n                assert hunk.source().range().start() == 0;\n                assert hunk.source().range().count() == 0;\n                assert hunk.source().lines().size() == 0;\n\n                fw.write(\"@@ -\");\n                fw.write(String.valueOf(hunk.source().range().start()));\n                fw.write(\",\");\n                fw.write(String.valueOf(hunk.source().range().count()));\n                fw.write(\" +\");\n                fw.write(String.valueOf(hunk.target().range().start()));\n                fw.write(\",\");\n                fw.write(String.valueOf(hunk.target().range().count()));\n                fw.write(\" @@\\n\");\n\n                for (var line : hunk.target().lines()) {\n                    fw.write(\"+\");\n                    fw.write(line);\n                    fw.write(\"\\n\");\n                }\n            }\n        }\n    }\n}\n\n\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/CDiffView.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.openjdk.skara.vcs.TextualPatch;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\n\nclass CDiffView implements View {\n    private Path out;\n    private Path file;\n    private TextualPatch patch;\n    private Navigation nav;\n    private List<String> sourceContent;\n    private List<String> destContent;\n\n    private static final int NUM_CONTEXT_LINES = 5;\n\n    public CDiffView(Path out, Path file, TextualPatch patch, Navigation nav, List<String> sourceContent, List<String> destContent) {\n        this.out = out;\n        this.file = file;\n        this.patch = patch;\n        this.nav = nav;\n        this.sourceContent = sourceContent;\n        this.destContent = destContent;\n    }\n\n    private void writeContext(Writer w, Context context) throws IOException {\n        for (var line : context.destinationLines()) {\n            w.write(\"  \");\n            w.write(HTML.escape(line.text()));\n            w.write(\"\\n\");\n        }\n    }\n\n    public void render(Writer w) throws IOException {\n        var suffix = \".cdiff.html\";\n        var cdiffFile = out.resolve(file.toString() + suffix);\n        Files.createDirectories(cdiffFile.getParent());\n\n        var map = new HashMap<String, String>();\n        map.put(\"${TYPE}\", \"Cdiff\");\n        map.put(\"${FILENAME}\", file.toString());\n        map.put(\"${CSS_URL}\", Webrev.relativeToCSS(out, cdiffFile));\n\n        try (var fw = Files.newBufferedWriter(cdiffFile)) {\n            ViewUtils.DIFF_HEADER_TEMPLATE.render(fw, map);\n            fw.write(\"\\n\");\n            fw.write(\"<body>\\n\");\n            ViewUtils.writeNavigation(out, fw, cdiffFile, nav, suffix);\n            ViewUtils.PRINT_FILE_TEMPLATE.render(fw, map);\n            fw.write(\"\\n\");\n\n            var coalescer = new HunkCoalescer(NUM_CONTEXT_LINES, sourceContent, destContent);\n            for (var group : coalescer.coalesce(patch.hunks())) {\n                fw.write(\"<hr />\\n\");\n                fw.write(\"<pre>\\n\");\n\n                fw.write(\"<span class=\\\"line-old-header\\\">*** \");\n                fw.write(HTML.escape(group.header().source().toString()));\n                fw.write(\" ***</span>\\n\");\n\n                var totalNumRemovedLines = group.hunks().stream().mapToInt(h -> h.removed().size()).sum();\n                if (totalNumRemovedLines > 0) {\n                    writeContext(fw, group.contextBefore());\n\n                    for (var hunk : group.hunks()) {\n                        var numRemovedLines = hunk.removed().size();\n                        var numAddedLines = hunk.added().size();\n\n                        for (var i = 0; i < numRemovedLines; i++) {\n                            var line = hunk.removed().get(i);\n                            if (i < numAddedLines) {\n                                fw.write(\"<span class=\\\"line-modified\\\">! \");\n                            } else {\n                                fw.write(\"<span class=\\\"line-removed\\\">- \");\n                            }\n                            fw.write(HTML.escape(line.text()));\n                            fw.write(\"</span>\\n\");\n                        }\n\n                        writeContext(fw, hunk.contextAfter());\n                    }\n                }\n\n                fw.write(\"<span class=\\\"line-new-header\\\">--- \");\n                fw.write(HTML.escape(group.header().target().toString()));\n                fw.write(\" ---</span>\\n\");\n\n                var totalNumAddedLines = group.hunks().stream().mapToInt(h -> h.added().size()).sum();\n                if (totalNumAddedLines > 0) {\n                    writeContext(fw, group.contextBefore());\n\n                    for (var hunk : group.hunks()) {\n                        var numRemovedLines = hunk.removed().size();\n                        var numAddedLines = hunk.added().size();\n\n                        for (var i = 0; i < numAddedLines; i++) {\n                            var line = hunk.added().get(i);\n                            if (i < numRemovedLines) {\n                                fw.write(\"<span class=\\\"line-modified\\\">! \");\n                            } else {\n                                fw.write(\"<span class=\\\"line-added\\\">+ \");\n                            }\n                            fw.write(HTML.escape(line.text()));\n                            fw.write(\"</span>\\n\");\n                        }\n\n                        writeContext(fw, hunk.contextAfter());\n                    }\n                }\n\n                fw.write(\"</pre>\\n\");\n            }\n\n            ViewUtils.writeNavigation(out, fw, cdiffFile, nav, suffix);\n            ViewUtils.DIFF_FOOTER_TEMPLATE.render(fw, map);\n        }\n\n        w.write(\"<a href=\\\"\");\n        w.write(Webrev.relativeToIndex(out, cdiffFile));\n        w.write(\"\\\">Cdiffs</a>\\n\");\n    }\n}\n\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/DiffTooLargeException.java",
    "content": "/*\n * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\npublic class DiffTooLargeException extends Exception {\n    public DiffTooLargeException() {\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/FileView.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\ninterface FileView extends View {\n    Stats stats();\n}\n\n\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/FramesView.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.openjdk.skara.vcs.TextualPatch;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\n\nclass FramesView implements View {\n    private final Path out;\n    private final Path file;\n    private final TextualPatch patch;\n    private final Navigation nav;\n    private final List<String> sourceContent;\n    private final List<String> destContent;\n    private final static int numContextLines = 20;\n\n    public FramesView(Path out, Path file, TextualPatch patch, Navigation nav, List<String> sourceContent, List<String> destContent) {\n        this.out = out;\n        this.file = file;\n        this.patch = patch;\n        this.nav = nav;\n        this.sourceContent = sourceContent;\n        this.destContent = destContent;\n    }\n\n    public void render(Writer w) throws IOException {\n        var suffix = \".frames.html\";\n        var framesFile = out.resolve(file + suffix);\n        Files.createDirectories(framesFile.getParent());\n\n        var header = new Template(new String[]{\n            \"<!DOCTYPE html>\",\n            \"<html>\",\n            \"  <head>\",\n            \"    <meta charset=\\\"utf-8\\\" />\",\n            \"    <title>${TYPE} ${FILENAME}</title>\",\n            \"    <link rel=\\\"stylesheet\\\" href=\\\"${CSS_URL}\\\" />\",\n            \"    <script type=\\\"text/javascript\\\" src=\\\"${JS_URL}\\\"></script>\",\n            \"  </head>\",\n            \"<body onkeypress=\\\"keypress(event);\\\">\",\n            \"<a name=\\\"0\\\"></a>\",\n            \"<hr />\",\n            \"<pre>\"\n        });\n\n        var footer = new Template(new String[]{\n            \"</pre>\",\n            \"<input id=\\\"eof\\\" value=\\\"${EOF_VALUE}\\\" type=\\\"hidden\\\" />\",\n            \"</body>\",\n            \"</html>\"\n        });\n\n        final var eofValue = patch.hunks().size() + 1;\n\n        var map = new HashMap<String, String>();\n        map.put(\"${TYPE}\", \"Frames\");\n        map.put(\"${FILENAME}\", file.toString());\n        map.put(\"${CSS_URL}\", Webrev.relativeToCSS(out, framesFile));\n        map.put(\"${JS_URL}\", Webrev.relativeToAncnavJS(out, framesFile));\n        map.put(\"${EOF_VALUE}\", String.valueOf(eofValue));\n\n        var oldFrame = out.resolve(file + \".lhs.html\");\n        var lastEnd = 0;\n        var maxLineNum = sourceContent.size();\n        try (var fw = Files.newBufferedWriter(oldFrame)) {\n            header.render(fw, map);\n            var hunks = patch.hunks();\n            for (var hunkIndex = 0; hunkIndex < hunks.size(); hunkIndex++) {\n                var hunk = hunks.get(hunkIndex);\n                var numSourceLines = hunk.source().lines().size();\n                var numDestLines = hunk.target().lines().size();\n                var start = numSourceLines == 0 && hunk.source().range().start() == 0 ?\n                    hunk.source().range().start() :\n                    hunk.source().range().start() - 1;\n\n                for (var i = lastEnd; i < start; i++) {\n                    ViewUtils.writeWithLineNumber(fw, sourceContent.get(i), i + 1, maxLineNum);\n                    fw.write(\"\\n\");\n                }\n                var anchorId = hunkIndex + 1;\n                fw.write(String.format(\"<a name=\\\"%d\\\" id=\\\"anc%d\\\"></a>\", anchorId, anchorId));\n                for (var i = 0; i < numSourceLines; i++) {\n                    if (i < numDestLines) {\n                        fw.write(\"<span class=\\\"line-modified\\\">\");\n                    } else {\n                        fw.write(\"<span class=\\\"line-removed\\\">\");\n                    }\n                    ViewUtils.writeWithLineNumber(fw, sourceContent.get(start + i), start + i + 1, maxLineNum);\n                    fw.write(\"</span>\\n\");\n                }\n                for (var i = numSourceLines; i < numDestLines; i++) {\n                    fw.write(\"\\n\");\n                }\n                lastEnd = start + numSourceLines;\n            }\n\n            for (var i = lastEnd; i < maxLineNum; i++) {\n                ViewUtils.writeWithLineNumber(fw, sourceContent.get(i), i + 1, maxLineNum);\n                fw.write(\"\\n\");\n            }\n\n            fw.write(String.format(\"<a name=\\\"%d\\\" id=\\\"anc%d\\\"></a>\", eofValue, eofValue));\n            fw.write(\"<b style=\\\"font-size: large; color: red\\\">--- EOF ---</b>\\n\");\n            for (var i = 0; i < 80; i++) {\n                fw.write(\"\\n\");\n            }\n            footer.render(fw, map);\n        }\n\n        var newFrame = out.resolve(file + \".rhs.html\");\n        lastEnd = 0;\n        maxLineNum = destContent.size();\n        try (var fw = Files.newBufferedWriter(newFrame)) {\n            header.render(fw, map);\n            var hunks = patch.hunks();\n            for (var hunkIndex = 0; hunkIndex < hunks.size(); hunkIndex++) {\n                var hunk = hunks.get(hunkIndex);\n                var numSourceLines = hunk.source().lines().size();\n                var numDestLines = hunk.target().lines().size();\n                var start = numDestLines == 0 && hunk.target().range().start() == 0 ?\n                    hunk.target().range().start() :\n                    hunk.target().range().start() - 1;\n\n                for (var i = lastEnd; i < start; i++) {\n                    ViewUtils.writeWithLineNumber(fw, destContent.get(i), i + 1, maxLineNum);\n                    fw.write(\"\\n\");\n                }\n                var anchorId = hunkIndex + 1;\n                fw.write(String.format(\"<a name=\\\"%d\\\" id=\\\"anc%d\\\"></a>\", anchorId, anchorId));\n                for (var i = 0; i < numDestLines; i++) {\n                    if (i < numSourceLines) {\n                        fw.write(\"<span class=\\\"line-modified\\\">\");\n                    } else {\n                        fw.write(\"<span class=\\\"line-added\\\">\");\n                    }\n                    ViewUtils.writeWithLineNumber(fw, destContent.get(start + i), start + i + 1, maxLineNum);\n                    fw.write(\"</span>\\n\");\n                }\n                for (var i = numDestLines; i < numSourceLines; i++) {\n                    fw.write(\"\\n\");\n                }\n                lastEnd = start + numDestLines;\n            }\n\n            for (var i = lastEnd; i < maxLineNum; i++) {\n                ViewUtils.writeWithLineNumber(fw, destContent.get(i), i + 1, maxLineNum);\n                fw.write(\"\\n\");\n            }\n            fw.write(String.format(\"<a name=\\\"%d\\\" id=\\\"anc%d\\\"></a>\", eofValue, eofValue));\n            fw.write(\"<b style=\\\"font-size: large; color: red\\\">--- EOF ---</b>\\n\");\n            for (var i = 0; i < 80; i++) {\n                fw.write(\"\\n\");\n            }\n            footer.render(fw, map);\n        }\n\n        var framesNavigation = out.resolve(file + \".frames.prev_next.html\");\n        try (var fw = Files.newBufferedWriter(framesNavigation)) {\n            ViewUtils.DIFF_HEADER_TEMPLATE.render(fw, map);\n            fw.write(\"<body>\\n\");\n            ViewUtils.writeNavigation(out, fw, framesFile, nav, \".frames.html\");\n            ViewUtils.DIFF_FOOTER_TEMPLATE.render(fw, map);\n        }\n\n        try (var fw = Files.newBufferedWriter(framesFile)) {\n            ViewUtils.DIFF_HEADER_TEMPLATE.render(fw, map);\n            fw.write(\"\\n\");\n            fw.write(\"<frameset rows=\\\"*,90\\\">\\n\");\n            fw.write(\"  <frameset cols=\\\"50%,50%\\\">\\n\");\n            fw.write(\"      <frame src=\\\"\");\n            fw.write(oldFrame.getFileName().toString());\n            fw.write(\"\\\" scrolling=\\\"auto\\\" name=\\\"oldFrame\\\" />\\n\");\n            fw.write(\"      <frame src=\\\"\");\n            fw.write(newFrame.getFileName().toString());\n            fw.write(\"\\\" scrolling=\\\"auto\\\" name=\\\"newFrame\\\" />\\n\");\n            fw.write(\"  </frameset>\\n\");\n            fw.write(\"  <frameset rows=\\\"60,30\\\">\\n\");\n            fw.write(\"      <frame src=\\\"\");\n            fw.write(Webrev.relativeToAncnavHTML(out, framesFile));\n            fw.write(\"\\\" scrolling=\\\"no\\\" marginwidth=\\\"0\\\" marginheight=\\\"0\\\" name=\\\"navigationFrame\\\" />\\n\");\n            fw.write(\"      <frame src=\\\"\");\n            fw.write(framesNavigation.getFileName().toString());\n            fw.write(\"\\\" scrolling=\\\"no\\\" marginwidth=\\\"0\\\" marginheight=\\\"0\\\" name=\\\"prev_next\\\" />\\n\");  \n            fw.write(\"  </frameset>\\n\");\n            fw.write(\"</html>\\n\");\n        }\n\n        w.write(\"<a href=\\\"\");\n        w.write(Webrev.relativeToIndex(out, framesFile));\n        w.write(\"\\\">Frames</a>\\n\");\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/FullView.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\n\nclass FullView implements View {\n    private final Path out;\n    private final Path path;\n    private final List<String> content;\n    private final String suffix;\n    private final String title;\n\n    public FullView(Path out, Path path, List<String> content, String suffix, String title) {\n        this.out = out;\n        this.path = path;\n        this.content = content;\n        this.suffix = suffix;\n        this.title = title;\n    }\n\n    public void render(Writer w) throws IOException {\n        var file = out.resolve(path.toString() + suffix);\n        Files.createDirectories(file.getParent());\n\n        var map = new HashMap<String, String>();\n        map.put(\"${TYPE}\", title);\n        map.put(\"${FILENAME}\", path.toString());\n        map.put(\"${CSS_URL}\", Webrev.relativeToCSS(out, file));\n\n        try (var fw = Files.newBufferedWriter(file)) {\n            ViewUtils.DIFF_HEADER_TEMPLATE.render(fw, map);\n            fw.write(\"\\n\");\n            fw.write(\"  <body>\\n\");\n            fw.write(\"    <pre>\\n\");\n\n            var maxLineNumber = content.size();\n            for (var i = 0; i < content.size(); i++) {\n                ViewUtils.writeWithLineNumber(fw, content.get(i), i + 1, maxLineNumber);\n                fw.write(\"\\n\");\n            }\n\n            fw.write(\"    </pre>\\n\");\n            ViewUtils.DIFF_FOOTER_TEMPLATE.render(fw, map);\n        }\n\n        w.write(\"<a href=\\\"\");\n        w.write(Webrev.relativeToIndex(out, file));\n        w.write(\"\\\">\");\n        w.write(title);\n        w.write(\"</a>\\n\");\n    }\n}\n\nclass OldView extends FullView {\n    public OldView(Path out, Path path, List<String> content) {\n        super(out, path, content, \"-.html\", \"Old\");\n    }\n}\n\nclass NewView extends FullView {\n    public NewView(Path out, Path path, List<String> content) {\n        super(out, path, content, \".html\", \"New\");\n    }\n}\n\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/HTML.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nclass HTML {\n    public static String escape(String s) {\n        return s.replace(\"&\", \"&amp;\")\n                .replace(\"<\", \"&lt;\")\n                .replace(\">\", \"&gt;\")\n                .replace(\"\\\"\", \"&quot;\")\n                .replace(\"'\", \"&#39;\");\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/HunkCoalescer.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.openjdk.skara.vcs.*;\n\nimport java.util.*;\n\nclass Line {\n    private final int number;\n    private final String text;\n\n    public Line(int number, String text) {\n        this.number = number;\n        this.text = text;\n    }\n\n    public int number() {\n        return number;\n    }\n\n    public String text() {\n        return text;\n    }\n}\n\nclass ContextHunk {\n    private List<Line> removed;\n    private List<Line> added;\n    private Context contextAfter;\n\n    public ContextHunk(List<Line> removed, List<Line> added, Context contextAfter) {\n        this.removed = removed;\n        this.added = added;\n        this.contextAfter = contextAfter;\n    }\n\n    public List<Line> removed() {\n        return removed;\n    }\n\n    public List<Line> added() {\n        return added;\n    }\n\n    public Context contextAfter() {\n        return contextAfter;\n    }\n}\n\nclass Header {\n    private final Range source;\n    private final Range target;\n\n    public Header(Range source, Range target) {\n        this.source = source;\n        this.target = target;\n    }\n\n    public Range source() {\n        return source;\n    }\n\n    public Range target() {\n        return target;\n    }\n}\n\nclass Context {\n    private final List<Line> sourceLines;\n    private final List<Line> destinationLines;\n\n    public Context(List<Line> sourceLines, List<Line> destinationLines) {\n        this.sourceLines = sourceLines;\n        this.destinationLines = destinationLines;\n    }\n\n    public List<Line> sourceLines() {\n        return sourceLines;\n    }\n\n    public List<Line> destinationLines() {\n        return destinationLines;\n    }\n}\n\nclass HunkGroup {\n    private final Header header;\n    private Context contextBefore;\n    private List<ContextHunk> hunks;\n\n    public HunkGroup(Header header, Context contextBefore, List<ContextHunk> hunks) {\n        this.header = header;\n        this.contextBefore = contextBefore;\n        this.hunks = hunks;\n    }\n\n    Header header() {\n        return header;\n    }\n\n    Context contextBefore() {\n        return contextBefore;\n    }\n\n    List<ContextHunk> hunks() {\n        return hunks;\n    }\n}\n\nclass HunkCoalescer {\n    private final int numContextLines;\n    private final List<String> sourceContent;\n    private final List<String> destContent;\n\n    public HunkCoalescer(int numContextLines, List<String> sourceContent, List<String> destContent) {\n        this.numContextLines = numContextLines;\n        this.sourceContent = sourceContent;\n        this.destContent = destContent;\n    }\n\n    public List<Hunk> nextGroup(LinkedList<Hunk> hunks) {\n        var hunksInRange = new ArrayList<Hunk>();\n        hunksInRange.add(hunks.removeFirst());\n\n        while (!hunks.isEmpty()) {\n            var next = hunks.peekFirst();\n            var last = hunksInRange.getLast();\n            var destEnd = last.target().range().end() + numContextLines;\n            var sourceEnd = last.source().range().end() + numContextLines;\n            var nextDestStart = next.target().range().start() - numContextLines;\n            var nextSourceStart = next.source().range().start() - numContextLines;\n            if (sourceEnd >= nextSourceStart ||\n                destEnd >= nextDestStart) {\n                hunksInRange.add(hunks.removeFirst());\n            } else {\n                break;\n            }\n        }\n        return hunksInRange;\n    }\n\n    private Header calculateCoalescedHeader(Hunk first, Hunk last) {\n        var sourceStart = first.source().range().start() - numContextLines;\n        sourceStart = Math.max(sourceStart, 1);\n\n        var destStart = first.target().range().start() - numContextLines;\n        destStart = Math.max(destStart, 1);\n\n        var sourceEnd = last.source().range().end() + numContextLines;\n        sourceEnd = Math.min(sourceEnd, sourceContent.size() + 1);\n\n        var destEnd = last.target().range().end() + numContextLines;\n        destEnd = Math.min(destEnd, destContent.size() + 1);\n\n        var sourceCount = sourceEnd - sourceStart;\n        var destCount = destEnd - destStart;\n\n        return new Header(new Range(sourceStart, sourceCount),\n                          new Range(destStart, destCount));\n    }\n\n    private Context createContextBeforeGroup(Header header, Hunk first) {\n        var sourceContextBeforeStart = header.source().start();\n        var sourceContextBeforeEnd = first.source().range().start();\n        var sourceBeforeContextCount = sourceContextBeforeEnd - sourceContextBeforeStart;\n\n        var destContextBeforeStart = header.target().start();\n        var destContextBeforeEnd = first.target().range().start();\n        var destBeforeContextCount = destContextBeforeEnd - destContextBeforeStart;\n\n        var beforeContextCount = Math.min(destBeforeContextCount, sourceBeforeContextCount);\n        assert beforeContextCount <= numContextLines;\n\n        sourceContextBeforeStart = sourceContextBeforeEnd - beforeContextCount;\n        destContextBeforeStart = destContextBeforeEnd - beforeContextCount;\n\n        var sourceContextBefore = new ArrayList<Line>();\n        for (var lineNum = sourceContextBeforeStart; lineNum < sourceContextBeforeEnd; lineNum++) {\n            sourceContextBefore.add(new Line(lineNum, sourceContent.get(lineNum - 1)));\n        }\n\n        var destContextBefore = new ArrayList<Line>();\n        for (var lineNum = destContextBeforeStart; lineNum < destContextBeforeEnd; lineNum++) {\n            destContextBefore.add(new Line(lineNum, destContent.get(lineNum - 1)));\n        }\n\n        return new Context(sourceContextBefore, destContextBefore);\n    }\n\n    private List<Line> removedLines(Hunk hunk) {\n        var removed = new ArrayList<Line>();\n\n        var removedStart = hunk.source().range().start();\n        var removedEnd = hunk.source().range().end();\n        for (var lineNum = removedStart; lineNum < removedEnd; lineNum++) {\n            var text = sourceContent.get(lineNum - 1);\n            removed.add(new Line(lineNum, text));\n        }\n\n        assert removed.size() == hunk.source().lines().size();\n\n        return removed;\n    }\n\n    private List<Line> addedLines(Hunk hunk) {\n        var added = new ArrayList<Line>();\n        var addedStart = hunk.target().range().start();\n        var addedEnd = hunk.target().range().end();\n        for (var lineNum = addedStart; lineNum < addedEnd; lineNum++) {\n            var text = destContent.get(lineNum - 1);\n            added.add(new Line(lineNum, text));\n        }\n\n        assert added.size() == hunk.target().lines().size();\n\n        return added;\n    }\n\n    private Context createContextAfterHunk(Hunk hunk, Hunk nextNonEmptySourceHunk, Hunk nextNonEmptyTargetHunk) {\n        var sourceAfterContextStart = hunk.source().range().end();\n        var sourceAfterContextEnd = hunk.source().range().end() + numContextLines;\n        if (nextNonEmptySourceHunk != null || nextNonEmptyTargetHunk != null) {\n            sourceAfterContextEnd += numContextLines; // include the \"before\" context for the next hunk\n        }\n        sourceAfterContextEnd = Math.min(sourceAfterContextEnd, sourceContent.size() + 1);\n        if (nextNonEmptySourceHunk != null) {\n            var nextNonEmptySourceHunkStart = nextNonEmptySourceHunk.source().range().start();\n            sourceAfterContextEnd = sourceAfterContextEnd > nextNonEmptySourceHunkStart\n                    ? Math.min(sourceAfterContextEnd, nextNonEmptySourceHunkStart)\n                    : Math.max(sourceAfterContextEnd, nextNonEmptySourceHunkStart);\n        }\n        var sourceAfterContextCount = sourceAfterContextEnd - sourceAfterContextStart;\n\n        var destAfterContextStart = hunk.target().range().end();\n        var destAfterContextEnd = hunk.target().range().end() + numContextLines;\n        if (nextNonEmptySourceHunk != null || nextNonEmptyTargetHunk != null) {\n            destAfterContextEnd += numContextLines; // include the \"before\" context for the next hunk\n        }\n        destAfterContextEnd = Math.min(destAfterContextEnd, destContent.size() + 1);\n        if (nextNonEmptyTargetHunk != null) {\n            var nextNonEmptyTargetHunkStart = nextNonEmptyTargetHunk.target().range().start();\n            destAfterContextEnd = destAfterContextEnd > nextNonEmptyTargetHunkStart\n                    ? Math.min(destAfterContextEnd, nextNonEmptyTargetHunkStart)\n                    : Math.max(destAfterContextEnd, nextNonEmptyTargetHunkStart);\n        }\n        var destAfterContextCount = destAfterContextEnd - destAfterContextStart;\n\n        var afterContextCount = Math.min(sourceAfterContextCount, destAfterContextCount);\n\n        var sourceLineNumStart = hunk.source().lines().isEmpty() && hunk.source().range().start() == 0 ?\n            sourceAfterContextStart + 1 : sourceAfterContextStart;\n        var sourceEndingLineNum = sourceLineNumStart + afterContextCount;\n        var sourceContextAfter = new ArrayList<Line>();\n        for (var lineNum = sourceLineNumStart; lineNum < sourceEndingLineNum; lineNum++) {\n            var text = sourceContent.get(lineNum - 1);\n            sourceContextAfter.add(new Line(lineNum, text));\n        }\n\n        var destLineNumStart = hunk.target().lines().isEmpty() && hunk.target().range().start() == 0 ?\n            destAfterContextStart + 1 : destAfterContextStart;\n        var destEndingLineNum = destLineNumStart + afterContextCount;\n        var destContextAfter = new ArrayList<Line>();\n        for (var lineNum = destLineNumStart; lineNum < destEndingLineNum; lineNum++) {\n            var text = destContent.get(lineNum - 1);\n            destContextAfter.add(new Line(lineNum, text));\n        }\n\n        return new Context(sourceContextAfter, destContextAfter);\n    }\n\n    public List<HunkGroup> coalesce(List<Hunk> originalHunks) {\n        var groups = new ArrayList<HunkGroup>();\n\n        var worklist = new LinkedList<>(originalHunks);\n        while (!worklist.isEmpty()) {\n            var hunkGroup = nextGroup(worklist);\n\n            var first = hunkGroup.get(0);\n            var last = hunkGroup.getLast();\n            var header = calculateCoalescedHeader(first, last);\n\n            var contextBefore = createContextBeforeGroup(header, first);\n\n            var hunksWithContext = new ArrayList<ContextHunk>();\n            for (var i = 0; i < hunkGroup.size(); i++) {\n                var hunk = hunkGroup.get(i);\n\n                var removed = removedLines(hunk);\n                var added = addedLines(hunk);\n\n                Hunk nextNonEmptySourceHunk = null;;\n                for (var j = i + 1; j < hunkGroup.size(); j++) {\n                    var next = hunkGroup.get(j);\n                    if (next.source().range().count() > 0) {\n                        nextNonEmptySourceHunk = next;\n                        break;\n                    }\n                }\n                Hunk nextNonEmptyTargetHunk = null;\n                for (var j = i + 1; j < hunkGroup.size(); j++) {\n                    var next = hunkGroup.get(j);\n                    if (next.target().range().count() > 0) {\n                        nextNonEmptyTargetHunk = next;\n                        break;\n                    }\n                }\n                var contextAfter = createContextAfterHunk(hunk, nextNonEmptySourceHunk, nextNonEmptyTargetHunk);\n\n                hunksWithContext.add(new ContextHunk(removed, added, contextAfter));\n            }\n\n            groups.add(new HunkGroup(header, contextBefore, hunksWithContext));\n        }\n\n        return groups;\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/IndexView.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.net.URISyntaxException;\nimport java.nio.file.Path;\nimport java.time.ZonedDateTime;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\n\nclass IndexView implements View {\n    private static final Template HEADER_TOP_TEMPLATE = new Template(new String[]{\n        \"<!DOCTYPE html>\",\n        \"<html>\",\n        \"  <head>\",\n        \"    <meta charset=\\\"utf-8\\\" />\",\n        \"    <title>${TITLE}</title>\",\n        \"    <link rel=\\\"stylesheet\\\" href=\\\"style.css\\\" />\",\n        \"    <link rel=\\\"shortcut icon\\\" type=\\\"image/x-icon\\\" href=\\\"nanoduke.ico\\\" />\",\n        \"  </head>\",\n        \"  <body>\",\n        \"    <div class=\\\"summary\\\">\",\n        \"      <h2 class=\\\"summary\\\">Code Review for ${TITLE}</h2>\",\n        \"      <table class=\\\"summary\\\">\"\n    });\n\n    private static final Template USER_TEMPLATE = new Template(new String[]{\n        \"        <tr>\",\n        \"          <th>Prepared by:</th>\",\n        \"          <td>${USER} on ${DATE}</td>\",\n        \"        </tr>\"\n    });\n\n    private static final Template UPSTREAM_TEMPLATE = new Template(new String[]{\n        \"        <tr>\",\n        \"          <th>Compare against:</th>\",\n        \"          <td><a href=\\\"${UPSTREAM}\\\">${UPSTREAM}</a></td>\",\n        \"        </tr>\"\n    });\n\n    private static final Template BRANCH_TEMPLATE = new Template(new String[]{\n        \"        <tr>\",\n        \"          <th>Branch:</th>\",\n        \"          <td>${BRANCH}</td>\",\n        \"        </tr>\"\n    });\n\n    private static final Template PR_TEMPLATE = new Template(new String[]{\n        \"        <tr>\",\n        \"          <th>Pull request:</th>\",\n        \"          <td><a href=\\\"${PR_HREF}\\\">${PR}</a></td>\",\n        \"        </tr>\"\n    });\n\n    private static final Template ISSUE_TEMPLATE = new Template(new String[]{\n        \"        <tr>\",\n        \"          <th>Bug id:</th>\",\n        \"          <td><a href=\\\"${ISSUE_HREF}\\\">${ISSUE}</a></td>\",\n        \"        </tr>\"\n    });\n\n    private static final Template REVISION_TEMPLATE = new Template(new String[]{\n        \"        <tr>\",\n        \"          <th>Compare against version:</th>\",\n        \"          <td>${REVISION}</td>\",\n        \"        </tr>\"\n    });\n\n    private static final Template REVISION_WITH_LINK_TEMPLATE = new Template(new String[]{\n        \"        <tr>\",\n        \"          <th>Compare against version:</th>\",\n        \"          <td><a href=\\\"${REVISION_HREF}\\\">${REVISION}</a></td>\",\n        \"        </tr>\"\n    });\n\n    private static final Template SUMMARY_TEMPLATE = new Template(new String[]{\n        \"        <tr>\",\n        \"          <th>Summary of changes:</th>\",\n        \"          <td>${STATS}</td>\",\n        \"        </tr>\"\n    });\n\n    private static final Template PATCH_TEMPLATE = new Template(new String[]{\n        \"        <tr>\",\n        \"          <th>Patch of changes:</th>\",\n        \"          <td><a href=\\\"${PATCH_URL}\\\">${PATCH}</a></td>\",\n        \"        </tr>\"\n    });\n\n    private static final Template AUTHOR_COMMENT_TEMPLATE = new Template(new String[]{\n        \"        <tr>\",\n        \"          <th>Author comments:</th>\",\n        \"          <td>\",\n        \"            <div>\",\n        \"${AUTHOR_COMMENT}\",\n        \"            </div>\",\n        \"          </td>\",\n        \"        </tr>\"\n    });\n\n    private static final Template HEADER_END_TEMPLATE = new Template(new String[]{\n       \"         <tr>\",\n       \"           <th>Legend:</th>\",\n       \"           <td><span class=\\\"file-modified\\\">Modified file</span><br><span class=\\\"file-removed\\\">Deleted file</span><br><span class=\\\"file-added\\\">New file</span></td>\",\n       \"        </tr>\",\n       \"      </table>\",\n       \"    </div>\"\n    });\n\n    private static final Template FOOTER_TEMPLATE = new Template(new String[]{\n        \"    <hr />\",\n        \"    <p class=\\\"version\\\">\",\n        \"      This code review page was prepared using <b>webrev</b> version ${VERSION}\",\n        \"    </p>\",\n        \"  </body>\",\n        \"</html>\"\n    });\n\n    private final List<FileView> files;\n    private final Map<String, String> map;\n\n    public IndexView(List<FileView> files,\n                     String title,\n                     String user,\n                     String upstream,\n                     String branch,\n                     String pullRequest,\n                     String issue,\n                     String version,\n                     Hash revision,\n                     String revisionURL,\n                     Path patchFile,\n                     Stats stats) {\n        this.files = files;\n        map = new HashMap<>();\n\n        if (user != null) {\n            map.put(\"${USER}\", user);\n        }\n\n        if (upstream != null) {\n            map.put(\"${UPSTREAM}\", upstream);\n        }\n\n        if (branch != null) {\n            map.put(\"${BRANCH}\", branch);\n        }\n\n        if (pullRequest != null) {\n            map.put(\"${PR_HREF}\", pullRequest);\n\n            try {\n                var uri = URI.create(pullRequest);\n                var id = Path.of(uri.getPath()).getFileName().toString();\n                map.put(\"${PR}\", id);\n            } catch (IllegalArgumentException e) {\n                map.put(\"${PR}\", pullRequest);\n            }\n        }\n\n\n        if (version == null) {\n            map.put(\"${VERSION}\", \"'unknown'\");\n        } else {\n            map.put(\"${VERSION}\", version);\n        }\n\n        if (issue != null) {\n            map.put(\"${ISSUE_HREF}\", issue);\n\n            try {\n                var uri = new URI(issue);\n                var path = Path.of(uri.getPath());\n                var name = path.getFileName().toString();\n                map.put(\"${ISSUE}\", name);\n            } catch (URISyntaxException e) {\n                map.put(\"${ISSUE_HREF}\", issue);\n            }\n        }\n\n        var now = ZonedDateTime.now();\n        var formatter = DateTimeFormatter.ofPattern(\"E LLL dd HH:mm:ss z yyyy\");\n        map.put(\"${DATE}\", now.format(formatter));\n\n        map.put(\"${TITLE}\", title);\n        map.put(\"${REVISION}\", revision.abbreviate());\n        if (revisionURL != null) {\n            map.put(\"${REVISION_HREF}\", revisionURL);\n        }\n        map.put(\"${PATCH}\", patchFile.toString());\n        map.put(\"${PATCH_URL}\", patchFile.toString());\n        map.put(\"${STATS}\", stats.toString());\n    }\n\n    public void render(Writer w) throws IOException {\n        HEADER_TOP_TEMPLATE.render(w, map);\n\n        if (map.containsKey(\"${USER}\")) {\n            USER_TEMPLATE.render(w, map);\n        }\n\n        if (map.containsKey(\"${UPSTREAM}\")) {\n            UPSTREAM_TEMPLATE.render(w, map);\n        }\n\n        if (map.containsKey(\"${REVISION_HREF}\")) {\n            REVISION_WITH_LINK_TEMPLATE.render(w, map);\n        } else {\n            REVISION_TEMPLATE.render(w, map);\n        }\n\n        if (map.containsKey(\"${BRANCH}\")) {\n            BRANCH_TEMPLATE.render(w, map);\n        }\n\n        SUMMARY_TEMPLATE.render(w, map);\n        PATCH_TEMPLATE.render(w, map);\n\n        if (map.containsKey(\"${AUTHOR_COMMENT}\")) {\n            AUTHOR_COMMENT_TEMPLATE.render(w, map);\n        }\n\n        if (map.containsKey(\"${PR}\") && map.containsKey(\"${PR_HREF}\")) {\n            PR_TEMPLATE.render(w, map);\n        }\n\n        if (map.containsKey(\"${ISSUE}\")) {\n            ISSUE_TEMPLATE.render(w, map);\n        }\n\n        HEADER_END_TEMPLATE.render(w, map);\n\n        for (var view : files) {\n            view.render(w);\n            w.write(\"\\n\");\n        }\n\n        FOOTER_TEMPLATE.render(w, map);\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/MetadataFormatter.java",
    "content": "/*\n * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.vcs.openjdk.Issue;\n\nimport java.util.function.Function;\n\nclass MetadataFormatter {\n    private final Function<String, String> issueLinker;\n\n    MetadataFormatter(Function<String, String> issueLinker) {\n        this.issueLinker = issueLinker;\n    }\n\n    String format(CommitMetadata metadata) {\n        var prefix = metadata.hash().abbreviate() + \": \";\n        var subject = metadata.message().get(0);\n        var issue = Issue.fromString(subject);\n        if (issueLinker != null && issue.isPresent()) {\n            var id = issue.get().id();\n            var desc = issue.get().description();\n            var url = issueLinker.apply(id);\n            return prefix + \"<a href=\\\"\" + url + \"\\\">\" + id + \"</a>: \" + desc;\n        }\n        return prefix + subject;\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/ModifiedFileView.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.charset.MalformedInputException;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\nclass ModifiedFileView implements FileView {\n    private final Patch patch;\n    private final Path out;\n    private final Navigation navigation;\n    private final List<CommitMetadata> commits;\n    private final MetadataFormatter formatter;\n    private final List<String> oldContent;\n    private final List<String> newContent;\n    private final byte[] binaryContent;\n    private final Stats stats;\n\n    public ModifiedFileView(ReadOnlyRepository repo, Hash base, Hash head, List<CommitMetadata> commits, MetadataFormatter formatter, Patch patch, Path out, Navigation navigation) throws IOException {\n        this.patch = patch;\n        this.out = out;\n        this.navigation = navigation;\n        this.commits = commits;\n        this.formatter = formatter;\n        if (patch.isTextual()) {\n            binaryContent = null;\n            var sourcePath = patch.source().path().orElseThrow(() ->\n                new IllegalArgumentException(\"Could not get source path for file with hash \" +\n                                                   patch.source().hash() + \" with target path\" +\n                                                   patch.target().path().get())\n            );\n\n            oldContent = repo.lines(sourcePath, base).orElseThrow(() ->\n                new IllegalArgumentException(\"Could not get content for file \" +\n                                                   sourcePath + \" at revision \" + base)\n            );\n            if (head == null) {\n                var path = repo.root().resolve(patch.target().path().get());\n                if (patch.target().type().get().isVCSLink()) {\n                    var tip = repo.head();\n                    var content = repo.lines(patch.target().path().get(), tip).orElseThrow(() ->\n                        new IllegalArgumentException(\"Could not get content for file \" +\n                                                           patch.target().path().get() +\n                                                           \" at revision \" + tip)\n                    );\n                    newContent = List.of(content.get(0) + \"-dirty\");\n                } else {\n                    List<String> lines = null;\n                    for (var charset : List.of(StandardCharsets.UTF_8, StandardCharsets.ISO_8859_1)) {\n                        try {\n                            lines = Files.readAllLines(repo.root().resolve(path), charset);\n                            break;\n                        } catch (MalformedInputException e) {\n                            continue;\n                        }\n                    }\n                    if (lines == null) {\n                        throw new IllegalStateException(\"Could not read \" + path + \" as UTF-8 nor as ISO-8859-1\");\n                    }\n                    newContent = lines;\n                }\n            } else {\n                newContent = repo.lines(patch.target().path().get(), head).orElseThrow(() ->\n                    new IllegalArgumentException(\"Could not get content for file \" +\n                                                       patch.target().path().get() +\n                                                       \" at revision \" + head)\n                );\n            }\n            stats = new Stats(patch.asTextualPatch().stats(), newContent.size());\n        } else {\n            oldContent = null;\n            newContent = null;\n            if (head == null) {\n                binaryContent = Files.readAllBytes(repo.root().resolve(patch.target().path().get()));\n            } else {\n                binaryContent = repo.show(patch.target().path().get(), head).orElseThrow(() ->\n                    new IllegalArgumentException(\"Could not get content for file \" +\n                                                       patch.target().path().get() +\n                                                       \" at revision \" + head)\n                );\n            }\n            stats = Stats.empty();\n        }\n    }\n\n    @Override\n    public Stats stats() {\n        return stats;\n    }\n\n    private void renderTextual(Writer w) throws IOException {\n        var targetPath = patch.target().path().get();\n        var cdiffView = new CDiffView(out, targetPath, patch.asTextualPatch(), navigation, oldContent, newContent);\n        cdiffView.render(w);\n\n        var udiffView = new UDiffView(out, targetPath, patch.asTextualPatch(), navigation, oldContent, newContent);\n        udiffView.render(w);\n\n        var sdiffView = new SDiffView(out, targetPath, patch.asTextualPatch(), navigation, oldContent, newContent);\n        sdiffView.render(w);\n\n        var framesView = new FramesView(out, targetPath, patch.asTextualPatch(), navigation, oldContent, newContent);\n        framesView.render(w);\n\n        var oldView = new OldView(out, targetPath, oldContent);\n        oldView.render(w);\n\n        var newView = new NewView(out, patch.source().path().get(), newContent);\n        newView.render(w);\n\n        var patchView = new PatchView(out, targetPath, patch.asTextualPatch(), oldContent, newContent);\n        patchView.render(w);\n\n        var rawView = new RawView(out, targetPath, newContent);\n        rawView.render(w);\n\n        w.write(\"  </code>\\n\");\n    }\n\n    private void renderBinary(Writer w) throws IOException {\n        w.write(\"------ ------ ------ ------ --- --- \");\n\n        var patchView = new PatchView(out, patch.target().path().get(), patch.asBinaryPatch());\n        patchView.render(w);\n\n        var rawView = new RawView(out, patch.target().path().get(), binaryContent);\n        rawView.render(w);\n\n        w.write(\"  </code>\\n\");\n    }\n\n    @Override\n    public void render(Writer w) throws IOException {\n        w.write(\"<p>\\n\");\n        w.write(\"  <code>\\n\");\n\n        if (patch.isBinary()) {\n            renderBinary(w);\n        } else {\n            renderTextual(w);\n        }\n\n        w.write(\"  <span class=\\\"file-modified\\\">\");\n        w.write(patch.target().path().get().toString());\n        w.write(\"</span>\");\n\n        if (patch.status().isRenamed()) {\n            w.write(\" <i>(was \");\n            w.write(patch.source().path().get().toString());\n            w.write(\")</i>\");\n        } else if (patch.status().isCopied()) {\n            w.write(\" <i>(copied from \");\n            w.write(patch.source().path().get().toString());\n            w.write(\")</i>\");\n        }\n\n        if (patch.target().type().get().isVCSLink()) {\n            w.write(\" <i>(submodule)</i>\\n\");\n        } else if (patch.isBinary()) {\n            w.write(\" <i>(binary)</i>\\n\");\n        } else {\n            w.write(\"\\n\");\n        }\n\n        w.write(\"<p>\\n\");\n\n        if (patch.isTextual()) {\n            w.write(\"<blockquote>\\n\");\n            if (!commits.isEmpty()) {\n                w.write(\"  <pre>\\n\");\n                w.write(commits.stream()\n                        .map(formatter::format)\n                        .collect(Collectors.joining(\"\\n\")));\n                w.write(\"  </pre>\\n\");\n            }\n            w.write(\"  <span class=\\\"stat\\\">\\n\");\n            w.write(stats.toString());\n            w.write(\"  </span>\");\n            w.write(\"</blockquote>\\n\");\n        }\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/Navigation.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport java.nio.file.Path;\n\nclass Navigation {\n    final Path previous;\n    final Path next;\n\n    Navigation(Path previous, Path next) {\n        this.previous = previous;\n        this.next = next;\n    }\n\n    Path previous() {\n        return previous;\n    }\n\n    Path next() {\n        return next;\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/PatchView.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.openjdk.skara.vcs.TextualPatch;\nimport org.openjdk.skara.vcs.BinaryPatch;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.List;\n\nclass PatchView implements View {\n    private final Path out;\n    private final Path file;\n    private final TextualPatch textualPatch;\n    private final BinaryPatch binaryPatch;\n    private final List<String> sourceContent;\n    private final List<String> destContent;\n    private static final int NUM_CONTEXT_LINES = 5;\n\n    public PatchView(Path out, Path file, TextualPatch patch, List<String> sourceContent, List<String> destContent) {\n        this.out = out;\n        this.file = file;\n        this.textualPatch = patch;\n        this.binaryPatch = null;\n        this.sourceContent = sourceContent;\n        this.destContent = destContent;\n    }\n\n    public PatchView(Path out, Path file, BinaryPatch patch) {\n        this.out = out;\n        this.file = file;\n        this.textualPatch = null;\n        this.binaryPatch = patch;\n        this.sourceContent = null;\n        this.destContent = null;\n    }\n\n    private void writeLine(Writer w, String prepend, Line line) throws IOException {\n        w.write(prepend);\n        w.write(line.text());\n        w.write(\"\\n\");\n    }\n\n    @Override\n    public void render(Writer w) throws IOException {\n        var patchFile = out.resolve(file.toString() + \".patch\");\n        Files.createDirectories(patchFile.getParent());\n\n        if (binaryPatch != null) {\n            renderBinary(patchFile);\n        } else {\n            renderTextual(patchFile);\n        }\n\n        w.write(\"<a href=\\\"\");\n        w.write(Webrev.relativeToIndex(out, patchFile));\n        w.write(\"\\\">Patch</a>\\n\");\n    }\n\n    private void renderBinary(Path patchFile) throws IOException {\n        try (var fw = Files.newBufferedWriter(patchFile)) {\n            var sourcePath = ViewUtils.pathWithUnixSeps(binaryPatch.source().path().get());\n            var targetPath = ViewUtils.pathWithUnixSeps(binaryPatch.target().path().get());\n            fw.write(\"diff a/\");\n            fw.write(sourcePath);\n            fw.write(\" b/\");\n            fw.write(targetPath);\n            fw.write(\"\\n\");\n            fw.write(\"Binary files \");\n            fw.write(sourcePath);\n            fw.write(\" and \");\n            fw.write(targetPath);\n            fw.write(\" differ\\n\");\n        }\n\n    }\n\n    private void renderTextual(Path patchFile) throws IOException {\n        try (var fw = Files.newBufferedWriter(patchFile)) {\n            fw.write(\"diff a/\");\n            fw.write(ViewUtils.pathWithUnixSeps(textualPatch.source().path().get()));\n            fw.write(\" b/\");\n            fw.write(ViewUtils.pathWithUnixSeps(textualPatch.target().path().get()));\n            fw.write(\"\\n\");\n            fw.write(\"--- a/\");\n            fw.write(ViewUtils.pathWithUnixSeps(textualPatch.source().path().get()));\n            fw.write(\"\\n\");\n            fw.write(\"+++ b/\");\n            fw.write(ViewUtils.pathWithUnixSeps(textualPatch.target().path().get()));\n            fw.write(\"\\n\");\n\n            var coalescer = new HunkCoalescer(NUM_CONTEXT_LINES, sourceContent, destContent);\n            for (var group : coalescer.coalesce(textualPatch.hunks())) {\n                var sourceRange = group.header().source();\n                var destRange = group.header().target();\n\n                fw.write(\"@@ -\");\n                fw.write(String.valueOf(sourceRange.start()));\n                fw.write(\",\");\n                fw.write(String.valueOf(sourceRange.count()));\n                fw.write(\" +\");\n                fw.write(String.valueOf(destRange.start()));\n                fw.write(\",\");\n                fw.write(String.valueOf(destRange.count()));\n                fw.write(\" @@\\n\");\n\n                for (var line : group.contextBefore().sourceLines()) {\n                    writeLine(fw, \" \", line);\n                }\n\n                for (var hunk : group.hunks()) {\n                    for (var line : hunk.removed()) {\n                        writeLine(fw, \"-\", line);\n                    }\n                    for (var line : hunk.added()) {\n                        writeLine(fw, \"+\", line);\n                    }\n                    for (var line : hunk.contextAfter().sourceLines()) {\n                        writeLine(fw, \" \", line);\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/RawView.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport java.io.IOException;\nimport java.io.Writer;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.List;\n\nclass RawView implements View {\n    private final Path out;\n    private final Path file;\n    private final List<String> text;\n    private final byte[] binary;\n\n    public RawView(Path out, Path file, List<String> text) {\n        this.out = out;\n        this.file = file;\n        this.text = text;\n        this.binary = null;\n    }\n\n    public RawView(Path out, Path file, byte[] binary) {\n        this.out = out;\n        this.file = file;\n        this.binary = binary;\n        this.text = null;\n    }\n\n    public void render(Writer w) throws IOException {\n        // If the raw file collides with a file generated by the webrev (such as index.html), rename it\n        var rawFile = Webrev.STATIC_FILES.contains(file.toString()) ?\n            out.resolve(\"_\" + file.toString()) :\n            out.resolve(file.toString());\n        Files.createDirectories(rawFile.getParent());\n\n        if (binary != null) {\n            Files.write(rawFile, binary);\n        } else {\n            Files.write(rawFile, text);\n        }\n\n        w.write(\"<a href=\\\"\");\n        w.write(Webrev.relativeToIndex(out, rawFile));\n        w.write(\"\\\">Raw</a>\\n\");\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/RemovedFileView.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.openjdk.skara.vcs.*;\n\nimport java.io.*;\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\nclass RemovedFileView implements FileView {\n    private final Patch patch;\n    private final Path out;\n    private final List<CommitMetadata> commits;\n    private final MetadataFormatter formatter;\n    private final List<String> oldContent;\n    private final byte[] binaryContent;\n    private final Stats stats;\n\n    public RemovedFileView(ReadOnlyRepository repo, Hash base, Hash head, List<CommitMetadata> commits, MetadataFormatter formatter, Patch patch, Path out) throws IOException {\n        this.patch = patch;\n        this.out = out;\n        this.commits = commits;\n        this.formatter = formatter;\n        if (patch.isTextual()) {\n            binaryContent = null;\n            oldContent = repo.lines(patch.source().path().get(), base).orElseThrow(IllegalArgumentException::new);\n            stats = new Stats(patch.asTextualPatch().stats(), oldContent.size());\n        } else {\n            oldContent = null;\n            binaryContent = repo.show(patch.source().path().get(), base).orElseThrow(IllegalArgumentException::new);\n            stats = Stats.empty();\n        }\n    }\n\n    @Override\n    public Stats stats() {\n        return stats;\n    }\n\n    @Override\n    public void render(Writer w) throws IOException {\n        w.write(\"<p>\\n\");\n        w.write(\"  <code>\\n\");\n        w.write(\"------ ------ ------ ------ \");\n\n        if (patch.isTextual()) {\n            var oldView = new OldView(out, patch.source().path().get(), oldContent);\n            oldView.render(w);\n\n            w.write(\" --- \");\n\n            var removedPatchView = new RemovedPatchView(out, patch.source().path().get(), patch.asTextualPatch());\n            removedPatchView.render(w);\n\n            var rawView = new RawView(out, patch.source().path().get(), oldContent);\n            rawView.render(w);\n        } else {\n            w.write(\" --- --- \");\n            var patchView = new RemovedPatchView(out, patch.source().path().get(), patch.asBinaryPatch());\n            patchView.render(w);\n\n            var rawView = new RawView(out, patch.source().path().get(), binaryContent);\n            rawView.render(w);\n        }\n\n        w.write(\"  </code>\\n\");\n        w.write(\"  <span class=\\\"file-removed\\\">\");\n        w.write(patch.source().path().get().toString());\n        w.write(\"</span>\");\n\n        if (patch.source().type().get().isVCSLink()) {\n            w.write(\" <i>(submodule)</i>\\n\");\n        } else if (patch.isBinary()) {\n            w.write(\" <i>(binary)</i>\\n\");\n        } else {\n            w.write(\"\\n\");\n        }\n\n        w.write(\"<p>\\n\");\n\n        if (patch.isTextual()) {\n            w.write(\"<blockquote>\\n\");\n            if (!commits.isEmpty()) {\n                w.write(\"  <pre>\\n\");\n                w.write(commits.stream()\n                        .map(formatter::format)\n                        .collect(Collectors.joining(\"\\n\")));\n                w.write(\"  </pre>\\n\");\n            }\n            w.write(\"  <span class=\\\"stat\\\">\\n\");\n            w.write(stats.toString());\n            w.write(\"  </span>\");\n            w.write(\"</blockquote>\\n\");\n        }\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/RemovedPatchView.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.openjdk.skara.vcs.TextualPatch;\nimport org.openjdk.skara.vcs.BinaryPatch;\n\nimport java.io.*;\nimport java.nio.file.*;\n\nclass RemovedPatchView implements View {\n    private final Path out;\n    private final Path file;\n    private final TextualPatch textualPatch;\n    private final BinaryPatch binaryPatch;\n\n    public RemovedPatchView(Path out, Path file, TextualPatch patch) {\n        this.out = out;\n        this.file = file;\n        this.textualPatch = patch;\n        this.binaryPatch = null;\n    }\n\n    public RemovedPatchView(Path out, Path file, BinaryPatch patch) {\n        this.out = out;\n        this.file = file;\n        this.textualPatch = null;\n        this.binaryPatch = patch;\n    }\n\n    @Override\n    public void render(Writer w) throws IOException {\n        var patchFile = out.resolve(file.toString() + \".patch\");\n        Files.createDirectories(patchFile.getParent());\n\n        if (binaryPatch != null) {\n            renderBinary(patchFile);\n        } else {\n            renderTextual(patchFile);\n        }\n\n        w.write(\"<a href=\\\"\");\n        w.write(Webrev.relativeToIndex(out, patchFile));\n        w.write(\"\\\">Patch</a>\\n\");\n    }\n\n    private void renderBinary(Path patchFile) throws IOException {\n        try (var fw = Files.newBufferedWriter(patchFile)) {\n            var sourcePath = ViewUtils.pathWithUnixSeps(binaryPatch.source().path().get());\n            fw.write(\"diff a/\");\n            fw.write(sourcePath);\n            fw.write(\" b/\");\n            fw.write(sourcePath);\n            fw.write(\"\\n\");\n            fw.write(\"Binary files \");\n            fw.write(sourcePath);\n            fw.write(\" and /dev/null differ\\n\");\n        }\n    }\n\n    private void renderTextual(Path patchFile) throws IOException {\n        try (var fw = Files.newBufferedWriter(patchFile)) {\n            fw.write(\"diff a/\");\n            fw.write(ViewUtils.pathWithUnixSeps(textualPatch.source().path().get()));\n            fw.write(\" b/\");\n            fw.write(ViewUtils.pathWithUnixSeps(textualPatch.source().path().get()));\n            fw.write(\"\\n\");\n            fw.write(\"--- a/\");\n            fw.write(ViewUtils.pathWithUnixSeps(textualPatch.source().path().get()));\n            fw.write(\"\\n\");\n            fw.write(\"+++ /dev/null\");\n            fw.write(\"\\n\");\n\n            var hunks = textualPatch.hunks();\n            if (hunks.size() == 1) {\n                var hunk = hunks.get(0);\n\n                assert hunk.target().range().start() == 0;\n                assert hunk.target().range().count() == 0;\n                assert hunk.target().lines().size() == 0;\n\n                fw.write(\"@@ -\");\n                fw.write(String.valueOf(hunk.source().range().start()));\n                fw.write(\",\");\n                fw.write(String.valueOf(hunk.source().range().count()));\n                fw.write(\" +\");\n                fw.write(String.valueOf(hunk.target().range().start()));\n                fw.write(\",\");\n                fw.write(String.valueOf(hunk.target().range().count()));\n                fw.write(\" @@\\n\");\n\n                for (var line : hunk.source().lines()) {\n                    fw.write(\"-\");\n                    fw.write(line);\n                    fw.write(\"\\n\");\n                }\n            }\n        }\n    }\n}\n\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/SDiffView.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.openjdk.skara.vcs.TextualPatch;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\n\nclass SDiffView implements View {\n    private final Path out;\n    private final Path file;\n    private final TextualPatch patch;\n    private final Navigation nav;\n    private final List<String> sourceContent;\n    private final List<String> destContent;\n    private final int maxLineNum;\n    private final static int NUM_CONTEXT_LINES = 20;\n\n    public SDiffView(Path out, Path file, TextualPatch patch, Navigation nav, List<String> sourceContent, List<String> destContent) {\n        this.out = out;\n        this.file = file;\n        this.patch = patch;\n        this.nav = nav;\n        this.sourceContent = sourceContent;\n        this.destContent = destContent;\n        this.maxLineNum = Math.max(sourceContent.size(), destContent.size());\n    }\n\n    private void writeLine(Writer w, Line line) throws IOException {\n        ViewUtils.writeWithLineNumber(w, line.text(), line.number(), maxLineNum);\n    }\n\n    private void writeContext(Writer w, Line line) throws IOException {\n        writeLine(w, line);\n        w.write(\"\\n\");\n    }\n\n    private void writeLine(Writer w, Line line, String kind) throws IOException {\n        w.write(\"<span class=\\\"line-\");\n        w.write(kind);\n        w.write(\"\\\">\");\n        writeLine(w, line);\n        w.write(\"</span>\\n\");\n    }\n\n    public void render(Writer w) throws IOException {\n        var suffix = \".sdiff.html\";\n        var sdiffFile = out.resolve(file + suffix);\n        Files.createDirectories(sdiffFile.getParent());\n\n        var map = new HashMap<String, String>();\n        map.put(\"${TYPE}\", \"Sdiff\");\n        map.put(\"${FILENAME}\", file.toString());\n        map.put(\"${CSS_URL}\", Webrev.relativeToCSS(out, sdiffFile));\n\n        try (var fw = Files.newBufferedWriter(sdiffFile)) {\n            ViewUtils.DIFF_HEADER_TEMPLATE.render(fw, map);\n            fw.write(\"\\n\");\n            fw.write(\"<body>\\n\");\n            ViewUtils.writeNavigation(out, fw, sdiffFile, nav, suffix);\n            ViewUtils.PRINT_FILE_TEMPLATE.render(fw, map);\n            fw.write(\"\\n\");\n\n            var coalescer = new HunkCoalescer(NUM_CONTEXT_LINES, sourceContent, destContent);\n            var groups = coalescer.coalesce(patch.hunks());\n\n            fw.write(\"<table>\\n\");\n            fw.write(\"<tr valign=\\\"top\\\">\\n\");\n\n            fw.write(\"<td>\\n\");\n            for (var group : groups) {\n                fw.write(\"<hr />\\n\");\n                fw.write(\"<pre>\\n\");\n\n                for (var line : group.contextBefore().sourceLines()) {\n                    writeContext(fw, line);\n                }\n\n                for (var hunk : group.hunks()) {\n                    var removed = hunk.removed();\n                    var numRemoved = removed.size();\n                    var numAdded = hunk.added().size();\n                    var numModified = Math.min(numAdded, numRemoved);\n\n                    for (var i = 0; i < numModified; i++) {\n                        writeLine(fw, removed.get(i), \"modified\");\n                    }\n\n                    if (numRemoved > numModified) {\n                        for (var i = numModified; i < numRemoved; i++) {\n                            writeLine(fw, removed.get(i), \"removed\");\n                        }\n                    } else {\n                        for (var i = numModified; i < numAdded; i++) {\n                            fw.write(\"\\n\");\n                        }\n                    }\n\n                    for (var line : hunk.contextAfter().sourceLines()) {\n                        writeContext(fw, line);\n                    }\n                }\n                fw.write(\"</pre>\\n\");\n            }\n            fw.write(\"</td>\\n\");\n\n            fw.write(\"<td>\\n\");\n            for (var group : groups) {\n                fw.write(\"<hr />\\n\");\n                fw.write(\"<pre>\\n\");\n\n                for (var line : group.contextBefore().destinationLines()) {\n                    writeContext(fw, line);\n                }\n\n                for (var hunk : group.hunks()) {\n                    var added = hunk.added();\n                    var numAdded = added.size();\n                    var numRemoved = hunk.removed().size();\n                    var numModified = Math.min(numAdded, numRemoved);\n\n                    for (var i = 0; i < numModified; i++) {\n                        writeLine(fw, added.get(i), \"modified\");\n                    }\n\n                    if (numAdded > numModified) {\n                        for (var i = numModified; i < numAdded; i++) {\n                            writeLine(fw, added.get(i), \"added\");\n                        }\n                    } else {\n                        for (var i = numModified; i < numRemoved; i++) {\n                            fw.write(\"\\n\");\n                        }\n                    }\n\n                    for (var line : hunk.contextAfter().destinationLines()) {\n                        writeContext(fw, line);\n                    }\n                }\n                fw.write(\"</pre>\\n\");\n            }\n            fw.write(\"</td>\\n\");\n\n            fw.write(\"</tr>\\n\");\n            fw.write(\"</table>\\n\");\n\n            ViewUtils.writeNavigation(out, fw, sdiffFile, nav, suffix);\n            ViewUtils.DIFF_FOOTER_TEMPLATE.render(fw, map);\n        }\n\n        w.write(\"<a href=\\\"\");\n        w.write(Webrev.relativeToIndex(out, sdiffFile));\n        w.write(\"\\\">Sdiffs</a>\\n\");\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/Stats.java",
    "content": "/*\n * Copyright (c) 2018, 2022, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.openjdk.skara.vcs.WebrevStats;\n\nclass Stats {\n    private final int added;\n    private final int removed;\n    private final int modified;\n    private final int total;\n\n    public Stats(WebrevStats stats, int total) {\n        this.added = stats.added();\n        this.removed = stats.removed();\n        this.modified = stats.modified();\n        this.total = total;\n    }\n\n    public Stats(int added, int removed, int modified, int total) {\n        this.added = added;\n        this.removed = removed;\n        this.modified = modified;\n        this.total = total;\n    }\n\n    public static Stats empty() {\n        return new Stats(0, 0, 0, 0);\n    }\n\n    public int added() {\n        return added;\n    }\n\n    public int removed() {\n        return removed;\n    }\n\n    public int modified() {\n        return modified;\n    }\n\n    public int changed() {\n        return added() + removed() + modified();\n    }\n\n    public int unchanged() {\n        return total() - changed();\n    }\n\n    public int total() {\n        return total;\n    }\n\n    @Override\n    public String toString() {\n        return String.format(\"%d lines changed; %d ins; %d del; %d mod; %d unchg\",\n                             changed(), added(), removed(), modified(), unchanged());\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/Template.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport java.io.*;\nimport java.util.Map;\n\nclass Template {\n    private final String[] template;\n\n    public Template(String[] template) {\n        this.template = template;\n    }\n\n    public void render(Writer w, Map<String, String> map) throws IOException {\n        for (var i = 0; i < template.length; i++) {\n            var s = template[i];\n            for (var key : map.keySet()) {\n                if (key.endsWith(\"_URL}\")) {\n                    s = s.replace(key, map.get(key));\n                } else {\n                    s = s.replace(key, HTML.escape(map.get(key)));\n                }\n            }\n            w.write(s);\n\n            if (i != template.length - 1) {\n                w.write(\"\\n\");\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/UDiffView.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.openjdk.skara.vcs.TextualPatch;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\n\nclass UDiffView implements View {\n    private Path out;\n    private Path file;\n    private TextualPatch patch;\n    private Navigation nav;\n    private final List<String> sourceContent;\n    private final List<String> destContent;\n\n    private static final int NUM_CONTEXT_LINES = 5;\n\n    public UDiffView(Path out, Path file, TextualPatch patch, Navigation nav, List<String> sourceContent, List<String> destContent) {\n        this.out = out;\n        this.file = file;\n        this.patch = patch;\n        this.nav = nav;\n        this.sourceContent = sourceContent;\n        this.destContent = destContent;\n    }\n\n    private void writeContext(Writer w, List<Line> lines) throws IOException {\n        for (var line : lines) {\n            w.write(\"  \");\n            w.write(HTML.escape(line.text()));\n            w.write(\"\\n\");\n        }\n    }\n\n    public void render(Writer w) throws IOException {\n        var suffix = \".udiff.html\";\n        var udiffFile = out.resolve(file + suffix);\n        Files.createDirectories(udiffFile.getParent());\n\n        var map = new HashMap<String, String>();\n        map.put(\"${TYPE}\", \"Udiff\");\n        map.put(\"${FILENAME}\", file.toString());\n        map.put(\"${CSS_URL}\", Webrev.relativeToCSS(out, udiffFile));\n\n        try (var fw = Files.newBufferedWriter(udiffFile)) {\n            ViewUtils.DIFF_HEADER_TEMPLATE.render(fw, map);\n            fw.write(\"\\n\");\n            fw.write(\"<body>\\n\");\n            ViewUtils.writeNavigation(out, fw, udiffFile, nav, suffix);\n            ViewUtils.PRINT_FILE_TEMPLATE.render(fw, map);\n            fw.write(\"\\n\");\n\n            var coalescer = new HunkCoalescer(NUM_CONTEXT_LINES, sourceContent, destContent);\n            for (var group : coalescer.coalesce(patch.hunks())) {\n                fw.write(\"<hr />\\n\");\n                fw.write(\"<pre>\\n\");\n\n                fw.write(\"<span class=\\\"line-new-header\\\">@@ -\");\n                fw.write(HTML.escape(group.header().source().toString()));\n                fw.write(\" +\");\n                fw.write(HTML.escape(group.header().target().toString()));\n                fw.write(\" @@</span>\\n\");\n\n                writeContext(fw, group.contextBefore().sourceLines());\n\n                for (var hunk : group.hunks()) {\n                    var numRemovedLines = hunk.removed().size();\n                    var numAddedLines = hunk.added().size();\n\n                    for (var i = 0; i < numRemovedLines; i++) {\n                        if (i < numAddedLines) {\n                            fw.write(\"<span class=\\\"udiff-line-modified-removed\\\">- \");\n                        } else {\n                            fw.write(\"<span class=\\\"udiff-line-removed\\\">- \");\n                        }\n                        fw.write(HTML.escape(hunk.removed().get(i).text()));\n                        fw.write(\"</span>\\n\");\n                    }\n                    for (var i = 0; i < numAddedLines; i++) {\n                        if (i < numRemovedLines) {\n                            fw.write(\"<span class=\\\"udiff-line-modified-added\\\">+ \");\n                        } else {\n                            fw.write(\"<span class=\\\"udiff-line-added\\\">+ \");\n                        }\n                        fw.write(HTML.escape(hunk.added().get(i).text()));\n                        fw.write(\"</span>\\n\");\n                    }\n\n                    writeContext(fw, hunk.contextAfter().destinationLines());\n                }\n\n                fw.write(\"</pre>\\n\");\n            }\n\n            ViewUtils.writeNavigation(out, fw, udiffFile, nav, suffix);\n            ViewUtils.DIFF_FOOTER_TEMPLATE.render(fw, map);\n        }\n\n        w.write(\"<a href=\\\"\");\n        w.write(Webrev.relativeToIndex(out, udiffFile));\n        w.write(\"\\\">Udiffs</a>\\n\");\n    }\n}\n\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/View.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport java.io.*;\n\ninterface View {\n    void render(Writer w) throws IOException;\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/ViewUtils.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport java.io.*;\nimport java.nio.file.*;\n\nclass ViewUtils {\n    static final Template DIFF_HEADER_TEMPLATE = new Template(new String[]{\n        \"<!DOCTYPE html>\",\n        \"<html>\",\n        \"  <head>\",\n        \"    <meta charset=\\\"utf-8\\\" />\",\n        \"    <title>${TYPE} ${FILENAME}</title>\",\n        \"    <link rel=\\\"stylesheet\\\" href=\\\"${CSS_URL}\\\" />\",\n        \"  </head>\",\n    });\n\n    static final Template DIFF_FOOTER_TEMPLATE = new Template(new String[]{\n        \"  </body>\",\n        \"</html>\"\n    });\n\n    static final Template PRINT_FILE_TEMPLATE = new Template(new String[]{\n        \"    <h2>${FILENAME}</h2>\",\n        \"     <a class=\\\"print\\\" href=\\\"javascript:print()\\\">Print this page</a>\",\n    });\n\n    public static void writeNavigation(Path out, Writer w, Path current, Navigation nav, String suffix) throws IOException {\n        w.write(\"<center>\");\n        if (nav.previous() != null) {\n            w.write(\"<a href=\\\"\");\n            w.write(Webrev.relativeTo(current, out.resolve(nav.previous())) + suffix);\n            w.write(\"\\\" target=\\\"_top\\\">\");\n            w.write(HTML.escape(\"< prev\"));\n            w.write(\"</a>\");\n        } else {\n            w.write(HTML.escape(\"< prev\"));\n        }\n\n        w.write(\" \");\n        w.write(\"<a href=\\\"\");\n        w.write(Webrev.relativeTo(current, out.resolve(\"index.html\")));\n        w.write(\"\\\" target=\\\"_top\\\">index</a>\");\n        w.write(\" \");\n\n        if (nav.next() != null) {\n            w.write(\"<a href=\\\"\");\n            w.write(Webrev.relativeTo(current, out.resolve(nav.next())) + suffix);\n            w.write(\"\\\" target=\\\"_top\\\">\");\n            w.write(HTML.escape(\"next >\"));\n            w.write(\"</a>\");\n        } else {\n            w.write(HTML.escape(\"next >\"));\n        }\n\n        w.write(\"</center>\");\n    }\n\n    public static int numChars(int n) {\n        if (n < 0) {\n            throw new RuntimeException(\"Negative number: \" + n);\n        }\n\n        if (n < 10)       return 1;\n        if (n < 100)      return 2;\n        if (n < 1000)     return 3;\n        if (n < 10000)    return 4;\n        if (n < 100000)   return 5;\n        if (n < 1000000)  return 6;\n        if (n < 10000000) return 7;\n\n        throw new RuntimeException(\"Too long number: \" + n);\n    }\n\n    public static void writeWithLineNumber(Writer writer, String line, int lineNumber, int maxLineNumber) throws IOException {\n        var numSpace = numChars(maxLineNumber) - numChars(lineNumber);\n        for (var i = 0; i < numSpace; i++) {\n            writer.write(\" \");\n        }\n        writer.write(String.valueOf(lineNumber));\n        writer.write(\" \");\n        writer.write(HTML.escape(line));\n    }\n\n    public static String pathWithUnixSeps(Path p) {\n        return p.toString().replace('\\\\', '/');\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/Webrev.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.openjdk.skara.vcs.*;\nimport org.openjdk.skara.json.JSON;\n\nimport java.io.*;\nimport java.net.URI;\nimport java.net.URISyntaxException;\nimport java.nio.channels.FileChannel;\nimport java.nio.file.*;\nimport java.util.*;\nimport java.time.ZonedDateTime;\nimport java.time.format.DateTimeFormatter;\nimport java.util.logging.Logger;\nimport java.util.stream.Collectors;\nimport java.util.function.Function;\n\nimport static java.nio.file.StandardOpenOption.*;\n\npublic class Webrev {\n    private static final String ANCNAV_HTML = \"navigation.html\";\n    private static final String ANCNAV_JS = \"navigation.js\";\n\n    private static final String ICON = \"nanoduke.ico\";\n    private static final String CSS = \"style.css\";\n\n    private static final String INDEX = \"index.html\";\n\n    private static final Logger log = Logger.getLogger(\"org.openjdk.skara.webrev\");\n\n    private static final int TOTAL_CHANGES_THRESHOLD = 300000;\n\n    public static final Set<String> STATIC_FILES =\n        Set.of(ANCNAV_HTML, ANCNAV_JS, ICON, CSS, INDEX);\n\n    public static class RequiredBuilder {\n        private final ReadOnlyRepository repository;\n\n        RequiredBuilder(ReadOnlyRepository repository) {\n            this.repository = repository;\n        }\n\n        public Builder output(Path path) {\n            return new Builder(repository, path);\n        }\n    }\n\n    public static class Builder {\n        private final ReadOnlyRepository repository;\n        private final Path output;\n        private String title = \"webrev\";\n        private String username;\n        private URI upstreamURI;\n        private String upstreamName;\n        private URI forkURI;\n        private String forkName;\n        private String fork;\n        private String pullRequest;\n        private String branch;\n        private String issue;\n        private Function<String, String> issueLinker;\n        private Function<String, String> commitLinker;\n        private String version;\n        private List<Path> files = List.of();\n        private int similarity = 90;\n        private boolean comments;\n\n        Builder(ReadOnlyRepository repository, Path output) {\n            this.repository = repository;\n            this.output = output;\n        }\n\n        public Builder username(String username) {\n            this.username = username;\n            return this;\n        }\n\n        public Builder title(String title) {\n            this.title = title;\n            return this;\n        }\n\n        public Builder upstream(String name) {\n            this.upstreamName = name;\n            return this;\n        }\n\n        public Builder upstream(URI uri, String name) {\n            this.upstreamURI = uri;\n            this.upstreamName = name;\n            return this;\n        }\n\n        public Builder fork(String name) {\n            this.forkName = name;\n            return this;\n        }\n\n        public Builder fork(URI uri, String name) {\n            this.forkURI = uri;\n            this.forkName = name;\n            return this;\n        }\n\n        public Builder pullRequest(String pullRequest) {\n            this.pullRequest = pullRequest;\n            return this;\n        }\n\n        public Builder branch(String branch) {\n            this.branch = branch;\n            return this;\n        }\n\n        public Builder issue(String issue) {\n            this.issue = issue;\n            return this;\n        }\n\n        public Builder issueLinker(Function<String, String> issueLinker) {\n            this.issueLinker = issueLinker;\n            return this;\n        }\n\n        public Builder commitLinker(Function<String, String> commitLinker) {\n            this.commitLinker = commitLinker;\n            return this;\n        }\n\n        public Builder version(String version) {\n            this.version = version;\n            return this;\n        }\n\n        public Builder files(List<Path> files) {\n            this.files = files;\n            return this;\n        }\n\n        public Builder similarity(int similarity) {\n            this.similarity = similarity;\n            return this;\n        }\n\n        public Builder comments(boolean comments) {\n            this.comments = comments;\n            return this;\n        }\n\n        public void generate(Hash tailEnd) throws IOException, DiffTooLargeException {\n            generate(tailEnd, null);\n        }\n\n        public void generate(Hash tailEnd, Hash head) throws IOException, DiffTooLargeException {\n            var diff = head == null ?\n                    repository.diff(tailEnd, files, similarity) :\n                    repository.diff(tailEnd, head, files, similarity);\n            generate(diff, tailEnd, head);\n        }\n\n        public void generateJSON(Hash tailEnd, Hash head) throws IOException, DiffTooLargeException {\n            if (head == null) {\n                head = repository.head();\n            }\n            var diff = repository.diff(tailEnd, head, files);\n            generateJSON(diff, tailEnd, head);\n        }\n\n        public void generate(Diff diff) throws IOException, DiffTooLargeException {\n            generate(diff, diff.from(), diff.to());\n        }\n\n        public void generateJSON(Diff diff) throws IOException, DiffTooLargeException {\n            generateJSON(diff, diff.from(), diff.to());\n        }\n\n        private boolean hasMergeCommits(Hash tailEnd, Hash head) throws IOException {\n            var commits = repository.commitMetadata(tailEnd, head);\n            return commits.stream().anyMatch(CommitMetadata::isMerge);\n        }\n\n        private boolean diffTooLarge(Diff diff) {\n            var totalChanges = diff.patches().stream()\n                    .filter(Patch::isTextual)\n                    .map(Patch::asTextualPatch)\n                    .flatMap(textualPatch -> textualPatch.hunks().stream())\n                    .mapToInt(Hunk::changes)\n                    .sum();\n            return totalChanges > TOTAL_CHANGES_THRESHOLD;\n        }\n\n        private void generateJSON(Diff diff, Hash tailEnd, Hash head) throws IOException, DiffTooLargeException {\n            if (diffTooLarge(diff)) {\n                throw new DiffTooLargeException();\n            }\n            if (head == null) {\n                throw new IllegalArgumentException(\"Must supply a head hash\");\n            }\n            if (upstreamURI == null) {\n                throw new IllegalStateException(\"Must supply an URI to upstream repository\");\n            }\n            if (upstreamName == null) {\n                throw new IllegalStateException(\"Must supply a name for the upstream repository\");\n            }\n            if (forkURI == null) {\n                throw new IllegalStateException(\"Must supply an URI to fork repository\");\n            }\n            if (forkName == null) {\n                throw new IllegalStateException(\"Must supply a name for the fork repository\");\n            }\n\n            Files.createDirectories(output);\n            var metadata = JSON.object();\n            var now = ZonedDateTime.now();\n            metadata.put(\"created_at\", now.format(DateTimeFormatter.ISO_INSTANT));\n\n            var base = JSON.object();\n            base.put(\"sha\", tailEnd.hex());\n            base.put(\"repo\",\n                JSON.object().put(\"html_url\", upstreamURI.toString())\n                             .put(\"full_name\", upstreamName)\n            );\n            metadata.put(\"base\", base);\n\n            var headObj = JSON.object();\n            headObj.put(\"sha\", head.hex());\n            headObj.put(\"repo\",\n                JSON.object().put(\"html_url\", forkURI.toString())\n                             .put(\"full_name\", forkName)\n            );\n            metadata.put(\"head\", headObj);\n\n            var pathsPerCommit = new HashMap<Hash, List<Path>>();\n            var comparison = JSON.object();\n            var files = JSON.array();\n            for (var patch : diff.patches()) {\n                var file = JSON.object();\n                Path filename = null;\n                Path previousFilename = null;\n                String status = null;\n                if (patch.status().isModified()) {\n                    status = \"modified\";\n                    filename = patch.target().path().get();\n                } else if (patch.status().isAdded()) {\n                    status = \"added\";\n                    filename = patch.target().path().get();\n                } else if (patch.status().isDeleted()) {\n                    status = \"deleted\";\n                    filename = patch.source().path().get();\n                } else if (patch.status().isCopied()) {\n                    status = \"copied\";\n                    filename = patch.target().path().get();\n                    previousFilename = patch.source().path().get();\n                } else if (patch.status().isRenamed()) {\n                    status = \"renamed\";\n                    filename = patch.target().path().get();\n                    previousFilename = patch.source().path().get();\n                } else {\n                    throw new IllegalStateException(\"Unexpected status: \" + patch.status());\n                }\n\n                file.put(\"filename\", filename.toString());\n                file.put(\"status\", status);\n                if (previousFilename != null) {\n                    file.put(\"previous_filename\", previousFilename.toString());\n                }\n                if (patch.isBinary()) {\n                    file.put(\"binary\", true);\n                } else {\n                    file.put(\"binary\", false);\n                    var textualPatch = patch.asTextualPatch();\n\n                    file.put(\"additions\", textualPatch.additions());\n                    file.put(\"deletions\", textualPatch.deletions());\n                    file.put(\"changes\", textualPatch.changes());\n\n                    var sb = new StringBuilder();\n                    for (var hunk : textualPatch.hunks()) {\n                        sb.append(hunk.toString());\n                    }\n                    file.put(\"patch\", sb.toString());\n                }\n                files.add(file);\n                var commits = hasMergeCommits(tailEnd, head) ?\n                    repository.commitMetadata(repository.rangeInclusive(tailEnd, head), List.of(filename)) :\n                    repository.follow(filename, tailEnd, head);\n                for (var commit : commits) {\n                    if (!pathsPerCommit.containsKey(commit.hash())) {\n                        pathsPerCommit.put(commit.hash(), new ArrayList<>());\n                    }\n                    pathsPerCommit.get(commit.hash()).add(filename);\n                }\n            }\n            comparison.put(\"files\", files);\n\n            var commits = JSON.array();\n            for (var commit : repository.commitMetadata(tailEnd, head)) {\n                var c = JSON.object();\n                c.put(\"sha\", commit.hash().hex());\n                c.put(\"commit\",\n                    JSON.object().put(\"message\", String.join(\"\\n\", commit.message()))\n                );\n                var filesArray = JSON.array();\n                for (var path : pathsPerCommit.getOrDefault(commit.hash(), List.of())) {\n                    filesArray.add(JSON.object().put(\"filename\", path.toString()));\n                }\n                c.put(\"files\", filesArray);\n                commits.add(c);\n            }\n\n            Files.writeString(output.resolve(\"metadata.json\"), metadata.toString());\n            Files.writeString(output.resolve(\"comparison.json\"), comparison.toString());\n            Files.writeString(output.resolve(\"commits.json\"), commits.toString());\n        }\n\n        private void generate(Diff diff, Hash tailEnd, Hash head) throws IOException, DiffTooLargeException {\n            if (diffTooLarge(diff)) {\n                throw new DiffTooLargeException();\n            }\n            Files.createDirectories(output);\n\n            copyResource(ANCNAV_HTML);\n            copyResource(ANCNAV_JS);\n            copyResource(CSS);\n            copyResource(ICON);\n\n            var patches = diff.patches();\n            var patchFile = output.resolve(Path.of(title).getFileName().toString() + \".patch\");\n            if (files != null && !files.isEmpty()) {\n                // Sort the patches according to how they are listed in the `files` list.\n                var byTargetPath = new HashMap<Path, Patch>();\n                var bySourcePath = new HashMap<Path, Patch>();\n                for (var patch : patches) {\n                    if (patch.target().path().isPresent()) {\n                        byTargetPath.put(patch.target().path().get(), patch);\n                    } else {\n                        bySourcePath.put(patch.source().path().get(), patch);\n                    }\n                }\n\n                var sorted = new ArrayList<Patch>();\n                for (var file : files) {\n                    if (byTargetPath.containsKey(file)) {\n                        sorted.add(byTargetPath.get(file));\n                    } else if (bySourcePath.containsKey(file)) {\n                        sorted.add(bySourcePath.get(file));\n                    } else {\n                        log.warning(\"ignoring file not present in diff: \" + file);\n                    }\n                }\n                patches = sorted;\n            }\n\n            var modified = new ArrayList<Integer>();\n            for (var i = 0; i < patches.size(); i++) {\n                var patch = patches.get(i);\n                if (patch.status().isModified() || patch.status().isRenamed() || patch.status().isCopied()) {\n                    modified.add(i);\n                }\n            }\n\n            var navigations = new LinkedList<Navigation>();\n            for (var i = 0; i < modified.size(); i++) {\n                Path prev = null;\n                Path next = null;\n                if (i != 0) {\n                    prev = patches.get(modified.get(i - 1)).target().path().get();\n                }\n                if (i != modified.size() - 1) {\n                    next = patches.get(modified.get(i + 1)).target().path().get();\n                }\n                navigations.addLast(new Navigation(prev, next));\n            }\n\n            var headHash = head == null ? repository.head() : head;\n            var filesDesc = files.isEmpty() ? \"\" :\n                            \" for files \" +\n                            files.stream().map(Path::toString).collect(Collectors.joining(\", \"));\n            log.fine(\"Generating webrev from \" + tailEnd + \" to \" + headHash + filesDesc);\n\n            var fileViews = new ArrayList<FileView>();\n            var formatter = new MetadataFormatter(issueLinker);\n            for (var patch : patches) {\n                var status = patch.status();\n                var path = status.isDeleted() ?\n                    patch.source().path().get() :\n                    patch.target().path().get();\n                var commits = comments ? repository.commitMetadata(tailEnd, headHash, List.of(path)) : Collections.<CommitMetadata>emptyList();\n                if (status.isModified() || status.isRenamed() || status.isCopied()) {\n                    var nav = navigations.removeFirst();\n                    fileViews.add(new ModifiedFileView(repository, tailEnd, head, commits, formatter, patch, output, nav));\n                } else if (status.isAdded()) {\n                    fileViews.add(new AddedFileView(repository, tailEnd, head, commits, formatter, patch, output));\n                } else if (status.isDeleted()) {\n                    fileViews.add(new RemovedFileView(repository, tailEnd, head, commits, formatter, patch, output));\n                }\n            }\n\n            var total = fileViews.stream().map(FileView::stats).mapToInt(Stats::total).sum();\n            var stats = new Stats(diff.totalStats(), total);\n\n            var issueForWebrev = issue != null && issueLinker != null ? issueLinker.apply(issue) : null;\n            var tailEndURL = commitLinker != null ? commitLinker.apply(tailEnd.hex()) : null;\n            try (var w = Files.newBufferedWriter(output.resolve(INDEX))) {\n                var index = new IndexView(fileViews,\n                                          title,\n                                          username,\n                                          upstreamName,\n                                          branch,\n                                          pullRequest,\n                                          issueForWebrev,\n                                          version,\n                                          tailEnd,\n                                          tailEndURL,\n                                          output.relativize(patchFile),\n                                          stats);\n                index.render(w);\n\n            }\n\n            try (var totalPatch = FileChannel.open(patchFile, CREATE, WRITE)) {\n                for (var patch : patches) {\n                    var originalPath = patch.status().isDeleted() ? patch.source().path() : patch.target().path();\n                    var patchPath = output.resolve(originalPath.get().toString() + \".patch\");\n\n                    try (var patchFragment = FileChannel.open(patchPath, READ)) {\n                        var size = patchFragment.size();\n                        var n = 0;\n                        while (n < size) {\n                            n += patchFragment.transferTo(n, size, totalPatch);\n                        }\n                    }\n                }\n            }\n        }\n\n        private void copyResource(String name) throws IOException {\n            var stream = this.getClass().getResourceAsStream(\"/\" + name);\n            if (stream == null) {\n                Path classPath;\n                try {\n                    classPath = Path.of(getClass().getProtectionDomain().getCodeSource().getLocation().toURI());\n                } catch (URISyntaxException e) {\n                    throw new IOException(e);\n                }\n                var extPath = classPath.getParent().resolve(\"resources\").resolve(name);\n                stream = new FileInputStream(extPath.toFile());\n            }\n            Files.copy(stream, output.resolve(name));\n        }\n    }\n\n    public static RequiredBuilder repository(ReadOnlyRepository repository) {\n        return new RequiredBuilder(repository);\n    }\n\n    static String relativeTo(Path from, Path to) {\n        var relative = from.relativize(to);\n        return relative.subpath(1, relative.getNameCount()).toString();\n    }\n\n    static String relativeToCSS(Path out, Path file) {\n        return relativeTo(file, out.resolve(CSS));\n    }\n\n    static String relativeToIndex(Path out, Path file) {\n        return relativeTo(out.resolve(INDEX), file);\n    }\n\n    static String relativeToAncnavHTML(Path out, Path file) {\n        return relativeTo(file, out.resolve(ANCNAV_HTML));\n    }\n\n    static String relativeToAncnavJS(Path out, Path file) {\n        return relativeTo(file, out.resolve(ANCNAV_JS));\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/java/org/openjdk/skara/webrev/WebrevMetaData.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport java.io.IOException;\nimport java.net.URI;\nimport java.net.URISyntaxException;\nimport java.net.http.HttpClient;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.util.Optional;\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\n\npublic class WebrevMetaData {\n    private static final Pattern findPatchPattern = Pattern.compile(\n            \"[ ]*(?:<td>)?<a href=\\\".*\\\">(?<patchName>.*\\\\.patch)</a></td>(?:</tr>)?$\");\n\n    private final Optional<URI> patchURI;\n\n    public WebrevMetaData(Optional<URI> patchURI) {\n        this.patchURI = patchURI;\n    }\n\n    public static WebrevMetaData from(URI uri) throws IOException, URISyntaxException, InterruptedException {\n        var sanatizedUri = sanitizeURI(uri);\n        var patchFile = getPatchFile(sanatizedUri);\n\n        return new WebrevMetaData(patchFile);\n    }\n\n    private static String dropSuffix(String s, String suffix) {\n        if (s.endsWith(suffix)) {\n            s = s.substring(0, s.length() - suffix.length());\n        }\n        return s;\n    }\n\n    private static URI sanitizeURI(URI uri) throws URISyntaxException {\n        var path = dropSuffix(uri.getPath(), \"index.html\");\n        return new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), uri.getPort(),\n                       path, uri.getQuery(), uri.getFragment());\n    }\n\n    private static Optional<URI> getPatchFile(URI uri) throws IOException, InterruptedException {\n        var client = HttpClient.newHttpClient();\n        var findPatchFileRcequest = HttpRequest.newBuilder()\n                .uri(uri)\n                .build();\n        return client.send(findPatchFileRcequest, HttpResponse.BodyHandlers.ofLines())\n                .body()\n                .map(findPatchPattern::matcher)\n                .filter(Matcher::matches)\n                .findFirst()\n                .map(m -> m.group(\"patchName\"))\n                .map(uri::resolve);\n    }\n\n    public Optional<URI> patchURI() {\n        return patchURI;\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/resources/navigation.html",
    "content": "<!DOCTYPE html>\n<!--\n Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n\n This code is free software; you can redistribute it and/or modify it\n under the terms of the GNU General Public License version 2 only, as\n published by the Free Software Foundation.\n\n This code is distributed in the hope that it will be useful, but WITHOUT\n ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n version 2 for more details (a copy is included in the LICENSE file that\n accompanied this code).\n\n You should have received a copy of the GNU General Public License version\n 2 along with this work; if not, write to the Free Software Foundation,\n Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n\n Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n or visit www.oracle.com if you need additional information or have any\n questions.\n -->\n<html>\n  <head>\n    <meta charset=\"utf-8\" />\n    <title>Navigation</title>\n    <link rel=\"stylesheet\" href=\"style.css\" />\n    <script type=\"text/javascript\" src=\"navigation.js\"></script>\n  </head>\n  <body onkeydown=\"keydown(event)\">\n    <table class=\"navigation\">\n      <tr>\n        <td valign=\"middle\" width=\"25%\">\n          Diff navigation:\n          Use 'j' and 'k' for next and previous diffs; or use buttons at right\n        </td>\n        <td align=\"center\" valign=\"top\" width=\"50%\">\n          <div>\n            <table border=\"0\" align=\"center\">\n              <tr>\n                <td class=\"button\">\n                  <a onClick=\"goToStart()\" title=\"Go to Beginning Of file\">BOF</a>\n                </td>\n                <td class=\"button\">\n                  <a onMouseDown=\"scrollUp()\"\n                     onMouseUp=\"stopScrolling()\"\n                     onMouseOut=\"stopScrolling()\"\n                     onClick=\"return false\"\n                     title=\"Scroll Up: Press and Hold to accelerate\">Scroll Up</a>\n                </td>\n                <td class=\"button\">\n                  <a onClick=\"goToPrevHunk()\" title=\"Go to previous Diff\">Prev Diff</a>\n                </td>\n              </tr>\n              <tr>\n                <td class=\"button\">\n                  <a onClick=\"goToEnd()\"title=\"Go to End Of File\">EOF</a>\n                </td>\n                <td class=\"button\">\n                  <a onMouseDown=\"scrollDown()\"\n                     onMouseUp=\"stopScrolling()\"\n                     onMouseOut=\"stopScrolling()\"\n                     onClick=\"return false\"\n                     title=\"Scroll Down: Press and Hold to accelerate\">Scroll Down</a>\n                </td>\n                <td class=\"button\">\n                  <a onClick=\"goToNextHunk()\" title=\"Go to next Diff\" >Next Diff</a>\n                </td>\n              </tr>\n          </table>\n        </div>\n      </td>\n        <th valign=\"middle\" width=\"25%\">\n          <form>\n            <input id=\"display\" value=\"BOF\" size=\"8\" type=\"text\" />\n          </form>\n        </th>\n      </tr>\n    </table>\n  </body>\n</html>\n"
  },
  {
    "path": "webrev/src/main/resources/navigation.js",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nvar intervalId;\nvar isScrolling = false;\nvar acceleration = 3;\nvar currentHunk = 0;\nvar numCallbacks = 0;\n\nfunction getFrame(s) {\n    for (var i = 0; i < parent.frames.length; i++) {\n        var frame = parent.frames[i];\n        if (frame.name === s) {\n            return parent.frames[i];\n        }\n    }\n}\n\nfunction lastHunk() {\n    return getFrame(\"newFrame\").document.getElementById(\"eof\").value;\n}\n\nfunction toHunk(frame, n) {\n    frame.location.replace(frame.location.pathname + \"#\" + n);\n    frame.scrollBy(0, -30);\n    currentHunk = n;\n}\n\nfunction updateHunkDisplay(n) {\n    var value = n;\n    if (n == 0) {\n        value = \"BOF\";\n    } else if (n == lastHunk()) {\n        value = \"EOF\";\n    }\n    document.getElementById(\"display\").value = value;\n}\n\nfunction scrollToHunk(n) {\n    updateHunkDisplay(n);\n    toHunk(getFrame(\"oldFrame\"), n)\n    toHunk(getFrame(\"newFrame\"), n)\n}\n\nfunction stopScrolling() {\n    if (isScrolling) {\n        clearInterval(intervalId);\n        numCallbacks = 0;\n        acceleration = 3;\n    }\n}\n\nfunction scrollCallback() {\n    var n = direction * acceleration;\n    if (numCallbacks % 10 === 0) {\n        acceleration *= 1.2;\n    }\n    getFrame(\"oldFrame\").scrollBy(0, n);\n    getFrame(\"newFrame\").scrollBy(0, n);\n    numCallbacks++;\n}\n\nfunction scrollUp() {\n    isScrolling = true;\n    direction = -1;\n    intervalId = setInterval(scrollCallback, 10);\n}\n\nfunction scrollDown() {\n    isScrolling = true;\n    direction = 1;\n    intervalId = setInterval(scrollCallback, 10);\n}\n\nfunction goToStart() {\n    scrollToHunk(0);\n}\n\nfunction goToEnd() {\n    scrollToHunk(lastHunk());\n}\n\nfunction goToPrevHunk() {\n    var prev = currentHunk - 1;\n    if (prev < 0) {\n        prev = 0;\n    }\n    scrollToHunk(prev);\n}\n\nfunction goToNextHunk() {\n    var next = currentHunk + 1;\n    var last = lastHunk();\n    if (next >= last) {\n        next = last;\n    }\n    scrollToHunk(next);\n}\n\nfunction keydown(e) {\n    // KeyboardEvent.which is deprecated but still supported by all major\n    // browsers. Update this to e.g. KeyboardEvent.code once it gains broader\n    // support.\n    var key = String.fromCharCode(e.which);\n\n    if (key === \"k\") {\n        goToPrevHunk();\n    } else if (key === \"j\" || key === \" \") {\n        goToNextHunk();\n    }\n}\n"
  },
  {
    "path": "webrev/src/main/resources/style.css",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nbody {\n    background-color: #eeeeee;\n}\n\nspan.line-old-header {\n    color: red;\n    font-size: large;\n    font-weight: bold;\n}\n\nspan.line-new-header {\n    color: green;\n    font-size: large;\n    font-weight: bold;\n}\n\nspan.line-added {\n    color: blue;\n    font-weight: bold;\n}\n\nspan.line-modified {\n    color: blue;\n}\n\nspan.line-removed {\n    color: brown;\n}\n\nspan.udiff-line-added {\n    color: blue;\n}\n\nspan.udiff-line-modified-added {\n    color: blue;\n}\n\nspan.udiff-line-removed {\n    color: brown;\n}\n\nspan.udiff-line-modified-removed {\n    color: brown;\n}\n\nspan.file-modified {\n    font-weight: bold;\n}\n\nspan.file-renamed {\n    font-style: italic;\n}\n\nspan.file-added {\n    color: green;\n    font-weight: bold;\n}\n\nspan.file-removed {\n    color: red;\n    font-weight: bold;\n}\n\nhr {\n    border: none 0;\n    border-top: 1px solid #aaa;\n    height: 1px;\n}\n\ntable.navigation {\n    width: 100%;\n    border: 0;\n    align: center;\n}\n\ntd.button {\n    padding-left: 5px;\n    padding-right: 5px;\n    background-color: #eee;\n    text-align: center;\n    border: 1px #444 outset;\n    cursor: pointer;\n}\n\ntd.button a {\n    font-weight: bold;\n    color: black\n}\n\ntd.button:hover {\n    background: #ffcc99;\n}\n\ndiv.summary {\n    font-size: .8em;\n    border-bottom: 1px solid #aaa;\n    padding-left: 1em;\n    padding-right: 1em;\n}\n\nh2.summary {\n    margin-bottom: 0.3em;\n}\n\ntable.summary {\n    white-space: nowrap;\n}\n\ntable.summary th {\n    vertical-align: top;\n    text-align: right;\n}\n\nspan.stat {\n    font-size: .7em;\n}\n\np.version {\n    font-size: small;\n}\n"
  },
  {
    "path": "webrev/src/test/java/org/openjdk/skara/webrev/HTMLTests.java",
    "content": "/*\n * Copyright (c) 2018, 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\npublic class HTMLTests {\n    @Test\n    void testSingleQuoteEscape() {\n        var text = \"'7\";\n        var html = HTML.escape(text);\n        assertEquals(\"&#39;7\", html);\n    }\n}\n"
  },
  {
    "path": "webrev/src/test/java/org/openjdk/skara/webrev/HunkCoalescerTest.java",
    "content": "/*\n * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.junit.jupiter.api.Test;\nimport org.openjdk.skara.vcs.Hunk;\nimport org.openjdk.skara.vcs.Range;\n\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\npublic class HunkCoalescerTest {\n\n    @Test\n    void testOverlappingContextMerge() {\n        var sourceContent = List.of(\n                \"A\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"B\"\n        );\n\n        var targetContent = List.of(\n                \"C\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"D\"\n        );\n\n        var hunk1 = new Hunk(new Range(1, 1), List.of(\"A\"), new Range(1, 1), List.of(\"C\"));\n        var hunk2 = new Hunk(new Range(10, 1), List.of(\"B\"), new Range(10, 1), List.of(\"D\"));\n\n        var coalescer = new HunkCoalescer(5, sourceContent, targetContent);\n        var groups = coalescer.coalesce(List.of(hunk1, hunk2));\n\n        assertEquals(1, groups.size());\n        var group = groups.get(0);\n        assertEquals(10, group.header().source().count());\n        assertEquals(10, group.header().target().count());\n\n        assertEquals(2, group.hunks().size());\n        var contextHunk1 = group.hunks().get(0);\n        assertEquals(8, contextHunk1.contextAfter().sourceLines().size());\n        assertEquals(8, contextHunk1.contextAfter().destinationLines().size());\n    }\n\n    @Test\n    void testContextOverlapsContent() {\n        var sourceContent = List.of(\n                \"A\",\n                \"\",\n                \"\",\n                \"B\"\n        );\n\n        var targetContent = List.of(\n                \"C\",\n                \"\",\n                \"\",\n                \"D\"\n        );\n\n        var hunk1 = new Hunk(new Range(1, 1), List.of(\"A\"), new Range(1, 1), List.of(\"C\"));\n        var hunk2 = new Hunk(new Range(4, 1), List.of(\"B\"), new Range(4, 1), List.of(\"D\"));\n\n        var coalescer = new HunkCoalescer(5, sourceContent, targetContent);\n        var groups = coalescer.coalesce(List.of(hunk1, hunk2));\n\n        assertEquals(1, groups.size());\n        var group = groups.get(0);\n        assertEquals(4, group.header().source().count());\n        assertEquals(4, group.header().target().count());\n\n        assertEquals(2, group.hunks().size());\n        var contextHunk1 = group.hunks().get(0);\n        assertEquals(2, contextHunk1.contextAfter().sourceLines().size());\n        assertEquals(2, contextHunk1.contextAfter().destinationLines().size());\n    }\n\n    @Test\n    void testNonOverllapping() {\n        var sourceContent = List.of(\n                \"A\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"B\"\n        );\n\n        var targetContent = List.of(\n                \"C\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"\",\n                \"D\"\n        );\n\n        var hunk1 = new Hunk(new Range(1, 1), List.of(\"A\"), new Range(1, 1), List.of(\"C\"));\n        var hunk2 = new Hunk(new Range(14, 1), List.of(\"B\"), new Range(14, 1), List.of(\"D\"));\n\n        var coalescer = new HunkCoalescer(5, sourceContent, targetContent);\n        var groups = coalescer.coalesce(List.of(hunk1, hunk2));\n\n        assertEquals(2, groups.size());\n\n        var group1 = groups.get(0);\n        assertEquals(6, group1.header().source().count());\n        assertEquals(6, group1.header().target().count());\n\n        assertEquals(1, group1.hunks().size());\n        var contextHunk1 = group1.hunks().get(0);\n        assertEquals(5, contextHunk1.contextAfter().sourceLines().size());\n        assertEquals(5, contextHunk1.contextAfter().destinationLines().size());\n\n        var group2 = groups.get(1);\n        assertEquals(6, group2.header().source().count());\n        assertEquals(6, group2.header().target().count());\n\n        assertEquals(1, group2.hunks().size());\n        assertEquals(5, group2.contextBefore().sourceLines().size());\n        assertEquals(5, group2.contextBefore().destinationLines().size());\n    }\n}\n"
  },
  {
    "path": "webrev/src/test/java/org/openjdk/skara/webrev/WebrevTests.java",
    "content": "/*\n * Copyright (c) 2019, 2025, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.webrev;\n\nimport org.junit.jupiter.api.BeforeAll;\nimport org.openjdk.skara.test.TemporaryDirectory;\nimport org.openjdk.skara.test.TestableRepository;\nimport org.openjdk.skara.vcs.*;\n\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.EnumSource;\n\nimport java.io.IOException;\nimport java.nio.file.*;\n\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.junit.jupiter.api.Assumptions.assumeFalse;\n\nclass WebrevTests {\n\n    private static boolean hgAvailable = true;\n\n    @BeforeAll\n    static void checkHgAvailability() {\n        try {\n            var pb = new ProcessBuilder(\"hg\", \"--version\");\n            pb.redirectErrorStream(true);\n            var process = pb.start();\n            process.waitFor();\n            hgAvailable = (process.exitValue() == 0);\n        } catch (Exception e) {\n            hgAvailable = false;\n        }\n    }\n\n    void assertContains(Path file, String text) throws IOException {\n        var contents = Files.readString(file).replaceAll(\"\\\\R\", \"\\n\");\n        assertTrue(contents.contains(text));\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void simple(VCS vcs) throws IOException, DiffTooLargeException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var repoFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(repoFolder.path(), vcs);\n            var file = repoFolder.path().resolve(\"x.txt\");\n            Files.writeString(file, \"1\\n2\\n3\\n\");\n            repo.add(file);\n            var hash1 = repo.commit(\"Commit\", \"a\", \"a@a.a\");\n            Files.writeString(file, \"1\\n2\\n3\\n4\\n\");\n            repo.add(file);\n            var hash2 = repo.commit(\"Commit 2\", \"a\", \"a@a.a\");\n\n            new Webrev.Builder(repo, webrevFolder.path()).generate(hash1, hash2);\n            assertContains(webrevFolder.path().resolve(\"x.txt\"), \"1\\n2\\n3\\n4\\n\");\n            assertContains(webrevFolder.path().resolve(\"index.html\"), \"<td>1 lines changed; 1 ins; 0 del; 0 mod; 3 unchg</td>\");\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void middle(VCS vcs) throws IOException, DiffTooLargeException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var repoFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(repoFolder.path(), vcs);\n            var file = repoFolder.path().resolve(\"x.txt\");\n            Files.writeString(file, \"1\\n2\\n3\\n4\\n5\\n6\\n7\\n8\\n9\\n\");\n            repo.add(file);\n            var hash1 = repo.commit(\"Commit\", \"a\", \"a@a.a\");\n            Files.writeString(file, \"1\\n2\\n3\\n4\\n5\\n5.1\\n5.2\\n6\\n7\\n8\\n9\\n\");\n            repo.add(file);\n            var hash2 = repo.commit(\"Commit 2\", \"a\", \"a@a.a\");\n\n            new Webrev.Builder(repo, webrevFolder.path()).generate(hash1, hash2);\n            assertContains(webrevFolder.path().resolve(\"index.html\"), \"<td>2 lines changed; 2 ins; 0 del; 0 mod; 9 unchg</td>\");\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void emptySourceHunk(VCS vcs) throws IOException, DiffTooLargeException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var repoFolder = new TemporaryDirectory();\n        var webrevFolder = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(repoFolder.path(), vcs);\n            var file = repoFolder.path().resolve(\"x.txt\");\n            Files.writeString(file, \"1\\n2\\n3\\n\");\n            repo.add(file);\n            var hash1 = repo.commit(\"Commit\", \"a\", \"a@a.a\");\n            Files.writeString(file, \"0\\n1\\n2\\n3\\n\");\n            repo.add(file);\n            var hash2 = repo.commit(\"Commit 2\", \"a\", \"a@a.a\");\n\n            new Webrev.Builder(repo, webrevFolder.path()).generate(hash1, hash2);\n            assertContains(webrevFolder.path().resolve(\"index.html\"), \"<td>1 lines changed; 1 ins; 0 del; 0 mod; 3 unchg</td>\");\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void removedHeader(VCS vcs) throws IOException, DiffTooLargeException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var repoFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(repoFolder.path(), vcs);\n            var file = repoFolder.path().resolve(\"x.txt\");\n            Files.writeString(file, \"1\\n2\\n3\\n4\\n5\\n6\\n7\\n8\\n9\\n\");\n            repo.add(file);\n            var hash1 = repo.commit(\"Commit\", \"a\", \"a@a.a\");\n            Files.writeString(file, \"5\\n6\\n7\\n8\\n9\\n\");\n            repo.add(file);\n            var hash2 = repo.commit(\"Commit 2\", \"a\", \"a@a.a\");\n\n            new Webrev.Builder(repo, webrevFolder.path()).generate(hash1, hash2);\n            assertContains(webrevFolder.path().resolve(\"index.html\"), \"<td>4 lines changed; 0 ins; 4 del; 0 mod; 1 unchg</td>\");\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void removeBinaryFile(VCS vcs) throws IOException, DiffTooLargeException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var tmp = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(tmp.path().resolve(\"repo\"), vcs);\n            var binaryFile = repo.root().resolve(\"x.jpg\");\n            byte[] contents = {0x1, 0x2, 0x3, 0x4, 0x5, 0x0, 0x2, 0x3, 0x4, 0x5};\n            Files.write(binaryFile, contents);\n            repo.add(binaryFile);\n            var hash1 = repo.commit(\"Added binary file\", \"a\", \"a@a.a\");\n            repo.remove(binaryFile);\n            var hash2 = repo.commit(\"Removed binary file\", \"a\", \"a@a.a\");\n\n            new Webrev.Builder(repo, tmp.path().resolve(\"webrev\")).generate(hash1, hash2);\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void addBinaryFile(VCS vcs) throws IOException, DiffTooLargeException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var tmp = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(tmp.path().resolve(\"repo\"), vcs);\n            var readme = repo.root().resolve(\"README\");\n            Files.writeString(readme, \"Hello\\n\");\n            repo.add(readme);\n            var hash1 = repo.commit(\"Added readme\", \"a\", \"a@a\");\n\n            var binaryFile = repo.root().resolve(\"x.jpg\");\n            byte[] contents = {0x1, 0x2, 0x3, 0x4, 0x5, 0x0, 0x2, 0x3, 0x4, 0x5};\n            Files.write(binaryFile, contents);\n            repo.add(binaryFile);\n            var hash2 = repo.commit(\"Added binary file\", \"a\", \"a@a.a\");\n\n            new Webrev.Builder(repo, tmp.path().resolve(\"webrev\")).generate(hash1, hash2);\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void modifyBinaryFile(VCS vcs) throws IOException, DiffTooLargeException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var tmp = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(tmp.path().resolve(\"repo\"), vcs);\n            var readme = repo.root().resolve(\"README\");\n            var binaryFile = repo.root().resolve(\"x.jpg\");\n            byte[] contents = {0x1, 0x2, 0x3, 0x4, 0x5, 0x0, 0x2, 0x3, 0x4, 0x5};\n            Files.write(binaryFile, contents);\n            repo.add(binaryFile);\n            var hash1 = repo.commit(\"Added binary file\", \"a\", \"a@a.a\");\n\n            byte[] newContent =  {0x1, 0x2, 0x3, 0x4, 0x5, 0x0, 0x2, 0x3, 0x4, 0x5, 0x6};\n            Files.write(binaryFile, newContent);\n            repo.add(binaryFile);\n            var hash2 = repo.commit(\"Modified binary file\", \"a\", \"a@a.a\");\n\n            new Webrev.Builder(repo, tmp.path().resolve(\"webrev\")).generate(hash1, hash2);\n        }\n    }\n\n    @ParameterizedTest\n    @EnumSource(VCS.class)\n    void reservedName(VCS vcs) throws IOException, DiffTooLargeException {\n        assumeFalse(vcs == VCS.HG && !hgAvailable);\n        try (var repoFolder = new TemporaryDirectory();\n             var webrevFolder = new TemporaryDirectory()) {\n            var repo = TestableRepository.init(repoFolder.path(), vcs);\n            var file = repoFolder.path().resolve(\"index.html\");\n            Files.writeString(file, \"1\\n2\\n3\\n\");\n            repo.add(file);\n            var hash1 = repo.commit(\"Commit\", \"a\", \"a@a.a\");\n            Files.writeString(file, \"1\\n2\\n3\\n4\\n\");\n            repo.add(file);\n            var hash2 = repo.commit(\"Commit 2\", \"a\", \"a@a.a\");\n\n            new Webrev.Builder(repo, webrevFolder.path()).generate(hash1, hash2);\n            assertContains(webrevFolder.path().resolve(\"_index.html\"), \"1\\n2\\n3\\n4\\n\");\n            assertContains(webrevFolder.path().resolve(\"index.html\"), \"<td>1 lines changed; 1 ins; 0 del; 0 mod; 3 unchg</td>\");\n        }\n    }\n\n}\n"
  },
  {
    "path": "xml/build.gradle",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\n\nmodule {\n    name = 'org.openjdk.skara.xml'\n}\n\npublishing {\n    publications {\n        xml(MavenPublication) {\n            from components.java\n        }\n    }\n}\n"
  },
  {
    "path": "xml/src/main/java/module-info.java",
    "content": "/*\n * Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\nmodule org.openjdk.skara.xml {\n    requires java.xml;\n    exports org.openjdk.skara.xml;\n}\n"
  },
  {
    "path": "xml/src/main/java/org/openjdk/skara/xml/XML.java",
    "content": "/*\n * Copyright (c) 2018, 2024, Oracle and/or its affiliates. All rights reserved.\n * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.\n *\n * This code is free software; you can redistribute it and/or modify it\n * under the terms of the GNU General Public License version 2 only, as\n * published by the Free Software Foundation.\n *\n * This code is distributed in the hope that it will be useful, but WITHOUT\n * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or\n * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License\n * version 2 for more details (a copy is included in the LICENSE file that\n * accompanied this code).\n *\n * You should have received a copy of the GNU General Public License version\n * 2 along with this work; if not, write to the Free Software Foundation,\n * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.\n *\n * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA\n * or visit www.oracle.com if you need additional information or have any\n * questions.\n */\npackage org.openjdk.skara.xml;\n\nimport java.io.*;\nimport java.nio.file.*;\nimport java.util.*;\nimport org.w3c.dom.*;\nimport org.xml.sax.*;\nimport javax.xml.parsers.*;\n\npublic class XML {\n    public static Document parse(Path p) throws IOException {\n        return parse(new InputSource(Files.newInputStream(p)));\n    }\n\n    public static Document parse(String p) throws IOException {\n        try {\n            var factory = DocumentBuilderFactory.newInstance();\n            var builder = factory.newDocumentBuilder();\n            return builder.parse(new InputSource(new StringReader(p)));\n        } catch (ParserConfigurationException | SAXException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    public static Document parse(List<String> lines) throws IOException {\n        return parse(new InputSource(new StringReader(String.join(\"\\n\", lines))));\n    }\n\n    private static Document parse(InputSource source) throws IOException {\n        try {\n            var factory = DocumentBuilderFactory.newInstance();\n            var builder = factory.newDocumentBuilder();\n            return builder.parse(source);\n        } catch (ParserConfigurationException | SAXException e) {\n            throw new RuntimeException(e);\n        }\n    }\n\n    public static List<Element> children(Element element, String name) {\n        var result = new ArrayList<Element>();\n\n        var nodes = element.getChildNodes();\n        for (int i = 0; i < nodes.getLength(); i++) {\n            var node = nodes.item(i);\n            if (node.getNodeType() == Node.ELEMENT_NODE) {\n                Element child = (Element) node;\n                if (child.getTagName().equals(name)) {\n                    result.add(child);\n                }\n            }\n        }\n\n        return result;\n    }\n\n    public static List<Element> children(Document document, String name) {\n        var result = new ArrayList<Element>();\n\n        var nodes = document.getElementsByTagName(name);\n        for (int i = 0; i < nodes.getLength(); i++) {\n            var node = nodes.item(i);\n            if (node.getNodeType() == Node.ELEMENT_NODE) {\n                result.add((Element) node);\n            }\n        }\n\n        return result;\n    }\n\n    private static Element single(List<Element> elements) {\n        if (elements.size() > 1) {\n            throw new IllegalArgumentException(\"Too many children with name\");\n        }\n\n        return elements.isEmpty() ? null : elements.get(0);\n    }\n\n    public static Element child(Element element, String name) {\n        var elements = children(element, name);\n        return single(elements);\n    }\n\n    public static Element child(Document document, String name) {\n        var elements = children(document, name);\n        return single(elements);\n    }\n\n    public static String attribute(Element element, String name) {\n        return element.getAttribute(name);\n    }\n\n    public static boolean hasAttribute(Element element, String name) {\n        return element.hasAttribute(name);\n    }\n}\n"
  }
]