The branch, main has been created at fe7c654eb72a62cd50f28e01f454cd3798d5e261 (commit)
- Log ----------------------------------------------------------------- commit fe7c654eb72a62cd50f28e01f454cd3798d5e261 Author: softworkz <softwo...@hotmail.com> AuthorDate: Wed May 28 19:31:27 2025 +0200 Commit: softworkz <softwo...@hotmail.com> CommitDate: Wed May 28 19:31:27 2025 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a61601 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/__pycache__/ +patchwork.db diff --git a/COPYING.GPLv2 b/COPYING.GPLv2 new file mode 100644 index 0000000..15f75cf --- /dev/null +++ b/COPYING.GPLv2 @@ -0,0 +1,339 @@ +GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + <signature of Ty Coon>, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..59fccac --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# FFmpeg Patchwork CI Monitor for Azure DevOps + +This project adapts the FFmpeg Patchwork CI system to use Azure DevOps pipelines for running CI builds. +Instead of running builds locally using Docker containers, it triggers builds on Azure DevOps pipelines +and reports the results back to Patchwork. + +## Architecture + +The system consists of the following components: + +1. **Monitor Service**: A Python service that runs on a Linux VM and monitors Patchwork for new patches. +2. **Azure DevOps Pipelines**: CI pipelines that build FFmpeg and run tests. +3. **SQLite Database**: Local database for tracking builds and results. + +## Prerequisites + +- Python 3.6 or newer +- A Patchwork API token with write permissions +- An Azure DevOps Personal Access Token with build permissions +- Azure DevOps pipeline(s) configured for x86 (and optionally PPC) builds + +## Setup Instructions + +### 1. Create a Classic Pipeline in Azure DevOps + +1. Create a new pipeline in Azure DevOps using the Classic Editor +2. Configure the pipeline with the following variables (mark them as "Settable at queue time"): + - `patchUrl` - URL of the patch to download + - `patchSeriesId` - ID of the patch series + - `checkUrl` - URL for reporting check results back to Patchwork + - `jobName` - Name of the job (e.g., x86, ppc) +3. Add a secret variable `PATCHWORK_TOKEN` for authenticating with Patchwork +4. Add build steps for: + - Downloading the patch + - Applying the patch to FFmpeg + - Configuring and building FFmpeg + - Running FATE tests + - Reporting results back to Patchwork + +### 2. Install the Monitor Service + +#### Automated Installation (Linux) + +```bash +# Clone the repository +git clone https://your-repo-url/ffmpeg-patchwork-ci.git +cd ffmpeg-patchwork-ci + +# Install with deployment script +sudo ./deploy_to_linux.sh + +# Configure +sudo nano /etc/patchwork-ci/config.env + +# Start the service +sudo systemctl start patchwork-ci +sudo systemctl enable patchwork-ci +``` + +#### Manual Installation + +```bash +# Install dependencies +pip install requests python-dateutil pysocks + +# Configure environment variables +export PATCHWORK_HOST="patchwork.ffmpeg.org" +export PATCHWORK_TOKEN="your-patchwork-token" +export AZURE_DEVOPS_ORG="your-org" +export AZURE_DEVOPS_PROJECT="your-project" +export AZURE_DEVOPS_PAT="your-azure-pat" + +# Run the monitor +python run_patchwork_monitor.py --x86-pipeline-id 14 +``` + +## Command Line Options + +### Run Monitor + +``` +python run_patchwork_monitor.py [OPTIONS] + +Options: + --x86-pipeline-id INT Azure DevOps pipeline ID for x86 builds + --ppc-pipeline-id INT Azure DevOps pipeline ID for PPC builds (0 to disable) + --db-path PATH Path to SQLite database file + --patchwork-host HOST Patchwork host (e.g., patchwork.ffmpeg.org) + --patchwork-token TOKEN Patchwork API token + --azure-org ORG Azure DevOps organization + --azure-project PROJECT Azure DevOps project + --azure-pat PAT Azure DevOps Personal Access Token +``` + +## Utility Scripts + +### Test Azure DevOps Pipeline Triggering + +```bash +# Set environment variables +export AZURE_DEVOPS_ORG="your-org" +export AZURE_DEVOPS_PROJECT="your-project" +export AZURE_DEVOPS_PAT="your-pat" + +# Run the test +python test_azure_trigger.py +``` + +### Test Patchwork API Permissions + +```bash +# Basic token testing +python test_patchwork_permissions.py --token YOUR_PATCHWORK_TOKEN + +# Complete testing including check creation (requires patch ID) +python test_patchwork_permissions.py --token YOUR_PATCHWORK_TOKEN --patch-id 12345 + +# With custom host +python test_patchwork_permissions.py --token YOUR_PATCHWORK_TOKEN --host custom.patchwork.host +``` + +### Check Dependencies + +```bash +python check_deps.py +``` + +## Troubleshooting + +- **Azure DevOps Pipeline Not Triggering**: Check the Azure PAT permissions and ensure the pipeline ID is correct +- **Patchwork API Errors**: Use `test_patchwork_permissions.py` to check if your token has the necessary permissions +- **Monitor Not Finding Patches**: Check that the Patchwork host and token are configured correctly + +## License + +This project is licensed under the GPLv2 License - see the LICENSE file for details. diff --git a/check_deps.py b/check_deps.py new file mode 100644 index 0000000..56f823f --- /dev/null +++ b/check_deps.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +""" +Check dependencies for FFmpeg Patchwork CI Monitor + +This script checks if all required Python packages are installed +and helps troubleshoot common dependency issues. +""" + +import importlib +import sys +import subprocess + +REQUIRED_PACKAGES = [ + # Core requirements + ("requests", "pip install requests"), + ("dateutil", "pip install python-dateutil"), + ("sqlite3", "Built-in with Python (no installation needed)"), + + # No optional dependencies +] + +def check_package(package_name, install_command): + """Check if a package is installed and print installation command if not.""" + try: + importlib.import_module(package_name) + print(f"â {package_name} is installed") + return True + except ImportError: + print(f"â {package_name} is NOT installed") + print(f" To install: {install_command}") + return False + +def check_azure_cli(): + """Check if Azure CLI is installed (optional but helpful for testing).""" + try: + result = subprocess.run( + ["az", "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + if result.returncode == 0: + print("â Azure CLI is installed") + return True + else: + print("â Azure CLI is NOT installed (optional)") + return False + except FileNotFoundError: + print("â Azure CLI is NOT installed (optional)") + print(" To install: Visit https://docs.microsoft.com/en-us/cli/azure/install-azure-cli") + return False + +def check_environment(): + """Check for a properly configured environment.""" + # Check Python version + python_version = sys.version_info + if python_version.major < 3 or (python_version.major == 3 and python_version.minor < 6): + print(f"â Python version {python_version.major}.{python_version.minor} detected") + print(" Python 3.6 or newer is required") + else: + print(f"â Python version {python_version.major}.{python_version.minor} detected") + + print("\nChecking required packages:") + all_required_packages_found = True + for package_name, install_command in REQUIRED_PACKAGES: + if not check_package(package_name, install_command): + all_required_packages_found = False + + print("\nChecking optional tools:") + check_azure_cli() + + # Print summary + print("\nSummary:") + if all_required_packages_found: + print("â All required packages are installed") + print("â The FFmpeg Patchwork CI Monitor should run correctly") + else: + print("â Some required packages are missing") + print(" Please install the missing packages before running the monitor") + + return all_required_packages_found + +if __name__ == "__main__": + print("FFmpeg Patchwork CI Monitor Dependencies Check") + print("=============================================") + + check_environment() diff --git a/deploy_to_linux.sh b/deploy_to_linux.sh new file mode 100644 index 0000000..643f84b --- /dev/null +++ b/deploy_to_linux.sh @@ -0,0 +1,188 @@ +#!/bin/bash +# FFmpeg Patchwork CI Monitor - Linux Deployment Script +# This script sets up the Patchwork CI monitor to run in-place +# without copying files to system directories + +set -e + +# Color definitions +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print a colored status message +print_status() { + echo -e "${GREEN}==>${NC} $1" +} + +# Function to print an error message +print_error() { + echo -e "${RED}ERROR:${NC} $1" +} + +# Function to print a warning message +print_warning() { + echo -e "${YELLOW}WARNING:${NC} $1" +} + +# Check if running as root for systemd service installation +if [[ $EUID -ne 0 ]]; then + print_warning "Not running as root. Systemd service installation will be skipped." + print_warning "Run with sudo if you want to install the systemd service." + INSTALL_SERVICE=0 +else + INSTALL_SERVICE=1 +fi + +# Get the absolute path of the current directory +INSTALL_DIR=$(readlink -f "$(pwd)") +print_status "Using directory: $INSTALL_DIR" + +# Check for required files +if [[ ! -f "$INSTALL_DIR/patchwork_runner.py" ]]; then + print_error "patchwork_runner.py not found in the current directory" + exit 1 +fi + +if [[ ! -f "$INSTALL_DIR/run_patchwork_monitor.py" ]]; then + print_error "run_patchwork_monitor.py not found in the current directory" + exit 1 +fi + +if [[ ! -f "$INSTALL_DIR/sqlite_helper.py" ]]; then + print_error "sqlite_helper.py not found in the current directory" + exit 1 +fi + +# Install dependencies +print_status "Installing required dependencies" +if [[ $EUID -eq 0 ]]; then + apt-get update + apt-get install -y python3 python3-pip rsync curl + pip3 install requests==2.28.1 python-dateutil==2.8.2 +else + print_warning "Not running as root. Please install dependencies manually:" + echo "sudo apt-get update" + echo "sudo apt-get install -y python3 python3-pip rsync curl" + echo "pip3 install requests==2.28.1 python-dateutil==2.8.2" +fi + +# Run the dependency check +print_status "Verifying dependencies" +python3 "$INSTALL_DIR/check_deps.py" + +# Create local directories +print_status "Creating local directories" +mkdir -p "$INSTALL_DIR/logs" +chmod 755 "$INSTALL_DIR/logs" + +# Create configuration file +print_status "Creating local configuration file" +cat > "$INSTALL_DIR/config.env" << EOF +# Patchwork CI Monitor Configuration + +# Patchwork API configuration +PATCHWORK_TOKEN=your_token_here +PATCHWORK_HOST=patchwork.ffmpeg.org + +# Azure DevOps configuration +AZURE_DEVOPS_ORG=your_org_here +AZURE_DEVOPS_PROJECT=your_project_here +AZURE_DEVOPS_PAT=your_pat_here + +# Logging configuration +PATCHWORK_LOG_LEVEL=INFO + +# Database path (relative to installation directory) +PATCHWORK_DB_PATH="$INSTALL_DIR/patchwork.db" +EOF + +chmod 640 "$INSTALL_DIR/config.env" + +# Create systemd service +if [[ $INSTALL_SERVICE -eq 1 ]]; then + print_status "Creating systemd service" + cat > /etc/systemd/system/patchwork-ci.service << EOF +[Unit] +Description=FFmpeg Patchwork CI Monitor +After=network.target + +[Service] +Type=simple +# Since we're not copying files, the working directory is important +WorkingDirectory=$INSTALL_DIR +# Use full path to python3 and the script +ExecStart=/usr/bin/python3 $INSTALL_DIR/run_patchwork_monitor.py --config $INSTALL_DIR/config.env +Restart=always +RestartSec=60 +# Give the service 10 seconds to start up +TimeoutStartSec=10 +# Give the service 10 seconds to shut down +TimeoutStopSec=10 +# Set sensible security options +ProtectSystem=full +PrivateTmp=true +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target +EOF + + # Set up log rotation + print_status "Setting up log rotation" + cat > /etc/logrotate.d/patchwork-ci << EOF +$INSTALL_DIR/logs/*.log { + daily + rotate 14 + compress + delaycompress + missingok + notifempty + create 640 root root + sharedscripts + postrotate + systemctl restart patchwork-ci.service >/dev/null 2>&1 || true + endscript +} +EOF + + chmod 644 /etc/logrotate.d/patchwork-ci + + # Reload systemd + systemctl daemon-reload + print_status "Systemd service created: patchwork-ci.service" +fi + +# No convenience script needed - run the monitor directly with --config + +# Print installation summary and next steps +print_status "Installation complete!" +print_status "Please edit $INSTALL_DIR/config.env to set your configuration" + +if [[ $INSTALL_SERVICE -eq 1 ]]; then + print_status "To start the service: sudo systemctl start patchwork-ci" + print_status "To enable automatic startup: sudo systemctl enable patchwork-ci" + print_status "To view logs: sudo journalctl -u patchwork-ci -f" +else + print_status "To start manually: python3 run_patchwork_monitor.py --config config.env" + print_status "To view logs (redirect output with '>> logs/monitor.log 2>&1'): tail -f logs/monitor.log" +fi + +echo "" +print_warning "You MUST edit $INSTALL_DIR/config.env before starting" +echo "Specifically, set the following required values:" +echo " - PATCHWORK_TOKEN" +echo " - AZURE_DEVOPS_ORG" +echo " - AZURE_DEVOPS_PROJECT" +echo " - AZURE_DEVOPS_PAT" +echo "" +print_status "Note: Pipeline IDs are defined directly in patchwork_runner.py" +print_status "Multiple pipelines will be triggered for each patch:" +# echo " - linux_x64 (Pipeline ID: 14)" # Currently commented out in code +echo " - linux_x64_oot (Pipeline ID: 18)" # Note: ID is 18, not 16 +echo " - linux_x64_shared (Pipeline ID: 17)" +echo " - win_msvc_x64 (Pipeline ID: 19)" +echo " - win_gcc_x64 (Pipeline ID: 21)" +echo " - mac_x64 (Pipeline ID: 15)" +echo "Edit patchwork_runner.py to change these pipeline IDs if needed" diff --git a/job.py b/job.py new file mode 100644 index 0000000..20eb4c4 --- /dev/null +++ b/job.py @@ -0,0 +1,13 @@ +import subprocess +import sys + +class Job: + def __init__(self, name, config): + self.name = name + + # Required settings for Azure DevOps integration + self.azure_pipeline_id = config.get("azure_pipeline_id", 0) + # These properties are no longer used with Azure DevOps but kept for compatibility + self.build_flags = config.get("build_flags", "-j8") + self.fate_flags = config.get("fate_flags", "-j8") + self.run_full_series = config.get("run_full_series", True) # Default to True to process all patches diff --git a/logging_helpers.py b/logging_helpers.py new file mode 100644 index 0000000..81d40c4 --- /dev/null +++ b/logging_helpers.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +""" +Logging utilities for the FFmpeg Patchwork CI Monitor + +This module provides consistent logging functions used across +the patchwork monitoring scripts. +""" + +import os +from datetime import datetime + +# Log level constants +LOG_DEBUG = 10 +LOG_INFO = 20 +LOG_WARNING = 30 +LOG_ERROR = 40 + +# Default log level (INFO) +current_log_level = LOG_INFO + +# Log level names for display +log_level_names = { + LOG_DEBUG: "DEBUG", + LOG_INFO: "INFO", + LOG_WARNING: "WARNING", + LOG_ERROR: "ERROR" +} + +def set_log_level(): + """Set the log level based on environment variable""" + global current_log_level + log_level_str = os.environ.get("PATCHWORK_LOG_LEVEL", "INFO").upper() + + if log_level_str == "DEBUG": + current_log_level = LOG_DEBUG + elif log_level_str == "INFO": + current_log_level = LOG_INFO + elif log_level_str == "WARNING": + current_log_level = LOG_WARNING + elif log_level_str == "ERROR": + current_log_level = LOG_ERROR + + # Use direct print to avoid circular reference + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + # Add flush=True to ensure immediate output regardless of line endings + print(f"[{timestamp}] [INFO] Log level set to {log_level_str}", flush=True) + +def log_message(message, level=LOG_INFO): + """ + Print a log message with timestamp and level. + Ensures proper line ending handling regardless of source file format. + + Args: + message: The message to log + level: The log level (default: INFO) + """ + # Only log if the message level is at or above the current log level + if level >= current_log_level: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + level_name = log_level_names.get(level, "INFO") + + # Normalize message to ensure consistent line endings + if isinstance(message, str): + # Replace any Windows CRLF with just LF to ensure proper handling + message = message.replace('\r\n', '\n') + + # Add flush=True to ensure immediate output + print(f"[{timestamp}] [{level_name}] {message}", flush=True) diff --git a/patchwork_runner.py b/patchwork_runner.py new file mode 100644 index 0000000..e046362 --- /dev/null +++ b/patchwork_runner.py @@ -0,0 +1,537 @@ +#!/usr/bin/env python3 +""" +FFmpeg Patchwork CI Monitor for Azure DevOps + +This script monitors the Patchwork API for new FFmpeg patches and triggers +Azure DevOps builds to run CI tests, then reports results back to Patchwork. +""" + +import json +import os +import re +import requests +import subprocess +import sys +import time +import urllib.parse +import base64 + +from datetime import datetime, timezone +from dateutil.relativedelta import relativedelta +from job import Job +from sqlite_helper import SQLiteDatabase +from logging_helpers import log_message, set_log_level, LOG_DEBUG, LOG_INFO, LOG_WARNING, LOG_ERROR + +# Network request timeout in seconds +REQUEST_TIMEOUT = 30 + +# Environment variables +env = os.environ + +# Initialize log level from environment +set_log_level() + +# Patchwork configuration +patchwork_token = env.get("PATCHWORK_TOKEN", "") +patchwork_host = env.get("PATCHWORK_HOST", "patchwork.ffmpeg.org") + +# Database configuration +db_path = env.get("PATCHWORK_DB_PATH", "patchwork.db") + +# Azure DevOps configuration +azure_org = env.get("AZURE_DEVOPS_ORG", "") +azure_project = env.get("AZURE_DEVOPS_PROJECT", "") +azure_pat = env.get("AZURE_DEVOPS_PAT", "") + + +# Setup configuration for all pipelines - defined at module level so it can be imported +jobs_list = [] + +# # 14: linux-x64 +# config_linux_x64 = { +# "azure_pipeline_id": 14 +# } +# jobs_list.append(Job("linux_x64", config_linux_x64)) + +# 16: linux-x64-oot +config_linux_x64_oot = { + "azure_pipeline_id": 18 +} +jobs_list.append(Job("linux_x64_oot", config_linux_x64_oot)) + +# 17: linux-x64-shared +config_linux_x64_shared = { + "azure_pipeline_id": 17 +} +jobs_list.append(Job("linux_x64_shared", config_linux_x64_shared)) + +# 19: win-msvc-x64 +config_win_msvc_x64 = { + "azure_pipeline_id": 19 +} +jobs_list.append(Job("win_msvc_x64", config_win_msvc_x64)) + +# 21: win-gcc-x64 +config_win_gcc_x64 = { + "azure_pipeline_id": 21 +} +jobs_list.append(Job("win_gcc_x64", config_win_gcc_x64)) + +# 15: mac-x64 +config_mac_x64 = { + "azure_pipeline_id": 15 +} +jobs_list.append(Job("mac_x64", config_mac_x64)) + +def post_check(check_url, type_check, context, msg_short, msg_long): + """ + Post a check result to Patchwork + + Args: + check_url: URL endpoint for posting check results + type_check: Status type (success, fail, warning) + context: Context for the check (e.g., 'make_x86') + msg_short: Short description message + msg_long: Detailed message or log + """ + if isinstance(msg_long, bytes): + split_char = b'\n' + msg_long = msg_long.replace(b'\"', b'') + msg_long = msg_long.replace(b';', b'') + else: + split_char = '\n' + msg_long = msg_long.replace('\"', '') + msg_long = msg_long.replace(';', '') + + msg_long_split = msg_long.split(split_char) + if len(msg_long_split) > 200: + msg_long_split = msg_long_split[-200:] + + msg_long = split_char.join(msg_long_split) + + headers = {"Authorization": f"Token {patchwork_token}"} + payload = { + "state": type_check, + "context": context, + "description": msg_short, + "description_long": msg_long + } + + try: + resp = requests.post(check_url, headers=headers, data=payload, timeout=REQUEST_TIMEOUT) + print(resp) + print(resp.content) + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: + log_message(f"Request timeout or connection error when posting check: {str(e)}", LOG_ERROR) + +def regex_version_and_commit(subject): + """ + Extract version and commit entry information from a subject line + + Args: + subject: Email subject line to parse + + Returns: + Tuple of (version_num, commit_entry_num, commit_entry_den) + """ + subject_clean_re = re.compile(r'\[[^]]*\]\s+(\[[^]]*\])') + version_re = re.compile(r'[vV](\d+)') + commit_entry_re = re.compile(r'(\d+)/(\d+)') + + subject_clean_match = subject_clean_re.match(subject) + if subject_clean_match is None: + return 1, 1, 1 + + label = subject_clean_re.match(subject).group(1) + version_match = version_re.search(label) + + if version_match is None: + version_num = 1 + else: + version_num = int(version_match.group(1)) + + commit_entry_match = commit_entry_re.search(label) + if commit_entry_match is None: + commit_entry_num = 1 + commit_entry_den = 1 + else: + commit_entry_num = int(commit_entry_match.group(1)) + commit_entry_den = int(commit_entry_match.group(2)) + + return version_num, commit_entry_num, commit_entry_den + +def trigger_azure_pipeline(job, patch): + """ + Trigger an Azure DevOps pipeline build + + Args: + job: Job object containing configuration + patch: Dictionary with patch information + + Returns: + Dictionary with build information including URL and ID + """ + log_message(f"Starting trigger_azure_pipeline for job {job.name}") + pipeline_id = job.azure_pipeline_id + + if not pipeline_id: + log_message(f"No pipeline ID configured for job {job.name}") + return None + + if not azure_pat or not azure_org or not azure_project: + log_message("Azure DevOps configuration is incomplete - cannot trigger pipeline") + return None + + # Create authorization header with PAT + log_message("Preparing Azure DevOps API request") + auth = base64.b64encode(f":{azure_pat}".encode()).decode() + + # Prepare API endpoint - use the queue endpoint for classic pipelines + api_url = f"https://dev.azure.com/{azure_org}/{azure_project}/_apis/build/builds?api-version=6.0" + + # Prepare parameters to pass to the pipeline + parameters = { + "patchUrl": patch["mbox"], + "patchSeriesId": str(patch["series_id"]), # Convert to string for classic pipeline + "checkUrl": patch["check_url"], + "jobName": job.name, + "patchName": patch["subject_email"] + } + + # Prepare request payload for classic pipeline + payload = { + "definition": { + "id": pipeline_id + }, + "parameters": json.dumps(parameters) # Classic pipelines require parameters as a JSON string + } + + # Set up headers + headers = { + "Authorization": f"Basic {auth}", + "Content-Type": "application/json" + } + + # Send request to Azure DevOps + log_message(f"Sending API request to Azure DevOps: POST {api_url}") + log_message(f"Pipeline ID: {pipeline_id}, Job: {job.name}") + log_message(f"Parameters: {json.dumps(parameters, indent=2)}") + + try: + response = requests.post(api_url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT) + log_message(f"Received response from Azure DevOps: {response.status_code}") + + if response.status_code >= 200 and response.status_code < 300: + build_info = response.json() + log_message(f"Successfully triggered pipeline: {build_info.get('id')}") + log_message(f"Build URL: {build_info.get('_links', {}).get('web', {}).get('href')}") + return build_info + else: + log_message(f"Failed to trigger pipeline: {response.status_code}") + log_message(f"Error response: {response.text}") + return None + except requests.exceptions.Timeout: + log_message(f"Timeout when connecting to Azure DevOps API", LOG_ERROR) + return None + except requests.exceptions.ConnectionError: + log_message(f"Connection error when connecting to Azure DevOps API", LOG_ERROR) + return None + except Exception as e: + log_message(f"Exception while triggering pipeline: {str(e)}", LOG_ERROR) + return None + +def create_database_tables(mydb): + """ + Create necessary database tables if they don't exist + + Args: + mydb: Database connection + """ + log_message("Checking database tables", LOG_DEBUG) + + tables = { + "patch": "(id INTEGER PRIMARY KEY AUTOINCREMENT, msg_id TEXT, subject_email TEXT)", + "series": "(id INTEGER PRIMARY KEY AUTOINCREMENT, series_id INTEGER)", + "builds": "(id INTEGER PRIMARY KEY AUTOINCREMENT, msg_id TEXT, job_name TEXT, build_id TEXT, " + "status TEXT, series_id INTEGER, started_at TEXT, completed_at TEXT)" + } + + created_tables = [] + + for table_name, schema in tables.items(): + # Log at DEBUG level for routine checks + log_message(f"Checking {table_name} table", LOG_DEBUG) + result = mydb.create_missing_table(table_name, schema) + + # Only log at INFO level if table was actually created + if result: + created_tables.append(table_name) + + # Log a summary message at INFO level + if created_tables: + log_message(f"Created the following tables: {', '.join(created_tables)}") + else: + log_message("All database tables already exist", LOG_DEBUG) + +def fetch_and_process_patches(mydb, jobs_list, time_interval): + """ + Fetch new patches from Patchwork and trigger builds + + Args: + mydb: Database connection + jobs_list: List of job configurations + time_interval: Time interval in minutes to look back for patches + + Returns: + List of processed patches + """ + log_message(f"Starting fetch_and_process_patches (looking back {time_interval:.2f} minutes)") + + # Ensure database tables exist before proceeding + create_database_tables(mydb) + + patch_list = [] + + headers = {"Authorization": f"Token {patchwork_token}", "Host": patchwork_host} + + # Look back based on the provided time interval + utc_time = datetime.utcnow() + utc_time = utc_time - relativedelta(minutes=time_interval) + str_time = utc_time.strftime("%Y-%m-%dT%H:%M:%S") + str_time = urllib.parse.quote(str_time) + url_request = f"/api/events/?category=patch-completed&since={str_time}" + url = f"https://{patchwork_host}{url_request}" + + # Use DEBUG level for routine API calls to reduce log noise during normal operation + log_message(f"Making API request to Patchwork: GET {url}", LOG_DEBUG) + try: + resp = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT) + log_message(f"Received response: {resp.status_code}", LOG_DEBUG) + + # Parse API response + reply_list = json.loads(resp.content) + except requests.exceptions.Timeout: + log_message(f"Request timeout when fetching events from Patchwork API", LOG_ERROR) + return [] + except requests.exceptions.ConnectionError: + log_message(f"Connection error when fetching events from Patchwork API", LOG_ERROR) + return [] + except Exception as e: + log_message(f"Unexpected error when fetching events: {str(e)}", LOG_ERROR) + return [] + + # Log result at INFO level if events found, DEBUG if empty (common case) + if reply_list: + log_message(f"Found {len(reply_list)} events") + else: + log_message("No events found in API response", LOG_DEBUG) + + for reply in reply_list: + log_message(f"Processing event ID: {reply['id']}", LOG_DEBUG) + + patch_url = reply["payload"]["patch"]["url"] + series_id = reply["payload"]["series"]["id"] + + event_id = reply["id"] + msg_id = reply["payload"]["patch"]["msgid"] + mbox = reply["payload"]["patch"]["mbox"] + + log_message(f"Fetching patch details from {patch_url}", LOG_DEBUG) + try: + resp_patch = requests.get(patch_url, headers=headers, timeout=REQUEST_TIMEOUT) + log_message(f"Patch details response: {resp_patch.status_code}", LOG_DEBUG) + + reply_patch = json.loads(resp_patch.content) + except requests.exceptions.Timeout: + log_message(f"Request timeout when fetching patch details from {patch_url}", LOG_ERROR) + continue + except requests.exceptions.ConnectionError: + log_message(f"Connection error when fetching patch details from {patch_url}", LOG_ERROR) + continue + except Exception as e: + log_message(f"Unexpected error when fetching patch details: {str(e)}", LOG_ERROR) + continue + + author_email = reply_patch["submitter"]["email"] + subject_email = reply_patch["headers"]["Subject"] + subject_email = subject_email.replace("\n", "") + subject_email = subject_email.replace('\"', '') + + subject_email = subject_email[:256] + msg_id = msg_id[:256] + + check_url = reply_patch["checks"] + keys = ["msg_id"] + res = mydb.query("patch", keys, f"WHERE msg_id = \"{msg_id}\"") + if res: + log_message(f"Patch {msg_id} already exists, skipping", LOG_DEBUG) + continue + + log_message(f"Adding patch {msg_id} to database") + log_message(f"Patch info - Author: {author_email}, Subject: {subject_email}, Series ID: {series_id}") + log_message(f"Patch URLs - Check: {check_url}, Patch: {patch_url}, Mbox: {mbox}") + + mydb.insert("patch", {"msg_id": msg_id, "subject_email": subject_email}) + + log_message(f"Adding patch to processing list") + patch_list.append({ + "msg_id": msg_id, + "series_id": series_id, + "event_id": event_id, + "mbox": mbox, + "author_email": author_email, + "subject_email": subject_email, + "check_url": check_url + }) + + log_message(f"Checking if series {series_id} exists in database") + keys = ["series_id"] + res = mydb.query("series", keys, f"WHERE series_id = {series_id}") + if not res: + log_message(f"Adding series {series_id} to database") + mydb.insert("series", {"series_id": series_id}) + + log_message(f"Number of patches in list: {len(patch_list)}", LOG_DEBUG) + + # Group patches by series_id and sort + log_message("Grouping and sorting patches by series_id", LOG_DEBUG) + series_patches = {} + for patch in patch_list: + series_id = patch["series_id"] + if series_id not in series_patches: + series_patches[series_id] = [] + series_patches[series_id].append(patch) + + # Sort each series by commit number in reverse order (last patch first) + for series_id, patches in series_patches.items(): + patches.sort(key=lambda p: regex_version_and_commit(p["subject_email"])[1], reverse=True) + + log_message(f"Found {len(series_patches)} unique series to process", LOG_DEBUG) + + # Process each series + for series_id, patches in series_patches.items(): + log_message(f"Processing series {series_id} with {len(patches)} patches (in reverse order)") + + # Process each patch in the series (last patch first) + for patch in patches: + # Check commit message through Patchwork API to validate it + log_message(f"Fetching check details for patch {patch['msg_id']} from {patch['check_url']}") + try: + resp_patch = requests.get(f"{patch['check_url']}/", headers=headers, timeout=REQUEST_TIMEOUT) + log_message(f"Check details response: {resp_patch.status_code}") + patch_data = json.loads(resp_patch.content) + except requests.exceptions.Timeout: + log_message(f"Request timeout when fetching check details from {patch['check_url']}", LOG_ERROR) + continue + except requests.exceptions.ConnectionError: + log_message(f"Connection error when fetching check details from {patch['check_url']}", LOG_ERROR) + continue + except Exception as e: + log_message(f"Unexpected error when fetching check details: {str(e)}", LOG_ERROR) + continue + + # Commit message check removed as check_commit_message.py no longer exists + + # Extract commit position for logging + _, commit_num, commit_den = regex_version_and_commit(patch["subject_email"]) + log_message(f"Processing patch {commit_num}/{commit_den} from series {series_id}") + + # Process all jobs for this patch + log_message(f"Processing jobs for patch {patch['msg_id']} (commit {commit_num}/{commit_den})") + for job in jobs_list: + log_message(f"Evaluating job {job.name} for patch {patch['msg_id']}") + + # Skip if not processing full series and not the last patch + if not job.run_full_series and commit_num != commit_den: + log_message(f"Skipping job {job.name} - not processing full series and not the last patch") + continue + + # Trigger Azure DevOps build instead of running locally + log_message(f"Triggering Azure DevOps pipeline for job {job.name}") + build_info = trigger_azure_pipeline(job, patch) + + if build_info: + log_message(f"Successfully triggered pipeline for job {job.name}, build ID: {build_info.get('id', 'unknown')}") + + # Add entry to track the build + log_message(f"Adding build record to database for job {job.name}") + mydb.insert("builds", { + "msg_id": patch["msg_id"], + "job_name": job.name, + "build_id": build_info.get("id", "unknown"), + "status": "in_progress", + "series_id": patch["series_id"], + "started_at": datetime.now().isoformat() + }) + log_message(f"Successfully added build record for job {job.name}") + else: + log_message(f"Failed to trigger pipeline for job {job.name}") + + # Post initial status to Patchwork (commented out) + # post_check( + # patch["check_url"], + # "pending", + # f"build_{job.name}", + # f"Build queued in Azure DevOps (Pipeline {job.azure_pipeline_id})", + # "" + # ) + # else: + # Failed to trigger build (commented out) + # post_check( + # patch["check_url"], + # "warning", + # f"build_{job.name}", + # "Failed to trigger Azure DevOps build", + # "Check server logs for details." + # ) + + return patch_list + +if __name__ == "__main__": + log_message("Starting FFmpeg Patchwork CI Monitor") + log_message(f"Database path: {db_path}") + log_message(f"Patchwork host: {patchwork_host}") + log_message(f"Number of configured jobs: {len(jobs_list)}") + + # Local database for storing cached job results + log_message("Initializing database connection") + mydb = SQLiteDatabase(db_path) + + # Create tables + create_database_tables(mydb) + + # In minutes + start_time = 0 + end_time = 0 + + log_message("Entering main monitoring loop") + while True: + time_interval = (end_time - start_time) / 60 + 10 + log_message(f"Starting new monitoring cycle (looking back {time_interval:.2f} minutes)") + start_time = time.time() + + try: + log_message("Fetching and processing patches") + patch_list = fetch_and_process_patches(mydb, jobs_list, time_interval) + + if not patch_list: + log_message("No patches found, sleeping for 1 minute") + time.sleep(60*1) + log_message("Waking up from sleep") + else: + log_message(f"Processed {len(patch_list)} patches in this cycle") + except Exception as e: + log_message(f"ERROR: Exception while processing patches: {str(e)}") + import traceback + log_message(f"Traceback: {traceback.format_exc()}") + log_message("Sleeping for 1 minute after error") + time.sleep(60) + log_message("Waking up from error sleep") + + end_time = time.time() + cycle_duration = end_time - start_time + log_message(f"Monitoring cycle completed in {cycle_duration:.2f} seconds") + + log_message("Closing database connection") + mydb.close() + log_message("FFmpeg Patchwork CI Monitor terminated") diff --git a/run_patchwork_monitor.py b/run_patchwork_monitor.py new file mode 100644 index 0000000..e6e444a --- /dev/null +++ b/run_patchwork_monitor.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +""" +FFmpeg Patchwork CI Monitor Runner + +This is a helper script that configures and runs the Patchwork CI Monitor +with proper command-line arguments and environment variable handling. +""" + +import argparse +import os +import sys +import time +import signal +from datetime import datetime + +# Import shared logging functions +from logging_helpers import log_message, set_log_level, LOG_DEBUG, LOG_INFO, LOG_WARNING, LOG_ERROR + +def load_config_file(config_path): + """Load environment variables from a configuration file""" + if not os.path.exists(config_path): + log_message(f"Config file not found: {config_path}", LOG_ERROR) + return False + + log_message(f"Loading configuration from: {config_path}") + + try: + with open(config_path, 'r') as config_file: + for line in config_file: + line = line.strip() + # Skip comments and empty lines + if not line or line.startswith('#'): + continue + + # Parse KEY=VALUE format + if '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + + # Remove quotes if present + if (value.startswith('"') and value.endswith('"')) or \ + (value.startswith("'") and value.endswith("'")): + value = value[1:-1] + + # Set environment variable + os.environ[key] = value + log_message(f"Set environment variable: {key}", LOG_DEBUG) + return True + except Exception as e: + log_message(f"Error loading config file: {str(e)}", LOG_ERROR) + return False + +def setup_args(): + """Parse command line arguments""" + log_message("Parsing command line arguments") + parser = argparse.ArgumentParser(description="Run the FFmpeg Patchwork CI Monitor") + + # Config file option (new) + parser.add_argument("--config", + help="Path to configuration file (KEY=VALUE format)") + + # Database configuration + parser.add_argument("--db-path", default=os.environ.get("PATCHWORK_DB_PATH", "patchwork.db"), + help="Path to SQLite database file") + + # Patchwork configuration + parser.add_argument("--patchwork-host", default=os.environ.get("PATCHWORK_HOST"), + help="Patchwork host (e.g., patchwork.ffmpeg.org)") + parser.add_argument("--patchwork-token", default=os.environ.get("PATCHWORK_TOKEN"), + help="Patchwork API token") + + # Azure DevOps configuration + parser.add_argument("--azure-org", default=os.environ.get("AZURE_DEVOPS_ORG"), + help="Azure DevOps organization") + parser.add_argument("--azure-project", default=os.environ.get("AZURE_DEVOPS_PROJECT"), + help="Azure DevOps project") + parser.add_argument("--azure-pat", default=os.environ.get("AZURE_DEVOPS_PAT"), + help="Azure DevOps Personal Access Token") + + return parser.parse_args() + +def validate_args(args): + """Validate required arguments and provide helpful error messages""" + log_message("Validating command line arguments") + missing = [] + + # Check mandatory arguments + if not args.patchwork_host: + missing.append("--patchwork-host or PATCHWORK_HOST") + if not args.patchwork_token: + missing.append("--patchwork-token or PATCHWORK_TOKEN") + if not args.azure_org: + missing.append("--azure-org or AZURE_DEVOPS_ORG") + if not args.azure_project: + missing.append("--azure-project or AZURE_DEVOPS_PROJECT") + if not args.azure_pat: + missing.append("--azure-pat or AZURE_DEVOPS_PAT") + + if missing: + log_message("ERROR: Missing required arguments:") + for arg in missing: + log_message(f" - {arg}") + log_message("Run with --help for more information.") + return False + + log_message("Command line arguments validation successful") + return True + +def signal_handler(sig, frame): + """Handle Ctrl+C and other termination signals""" + log_message("Received termination signal. Shutting down gracefully...") + sys.exit(0) + +def main(): + """Main function to run the monitor""" + # Initialize log level + set_log_level() + + log_message("FFmpeg Patchwork CI Monitor - Starting") + + # Parse arguments first to get potential config file path + args = setup_args() + + # Load configuration from file if provided + if args.config: + if not load_config_file(args.config): + log_message("Failed to load configuration file, continuing with defaults and CLI arguments") + + # Re-parse arguments to allow CLI to override values from config file + args = setup_args() + + # Validate arguments + if not validate_args(args): + sys.exit(1) + + # Set environment variables from arguments + log_message("Setting environment variables from arguments") + os.environ["PATCHWORK_HOST"] = args.patchwork_host + os.environ["PATCHWORK_TOKEN"] = args.patchwork_token + os.environ["AZURE_DEVOPS_ORG"] = args.azure_org + os.environ["AZURE_DEVOPS_PROJECT"] = args.azure_project + os.environ["AZURE_DEVOPS_PAT"] = args.azure_pat + os.environ["PATCHWORK_DB_PATH"] = args.db_path + + # Set up signal handlers for graceful shutdown + log_message("Setting up signal handlers for graceful shutdown") + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # Print configuration + log_message("Configuration:") + log_message(f" Patchwork Host: {args.patchwork_host}") + log_message(f" Azure DevOps Organization: {args.azure_org}") + log_message(f" Azure DevOps Project: {args.azure_project}") + log_message(f" Database Path: {args.db_path}") + + # Import from patchwork_runner.py + log_message("Importing patchwork_runner module") + import patchwork_runner + + # Initialize database using the same database setup from patchwork_runner + log_message("Initializing database connection") + mydb = patchwork_runner.SQLiteDatabase(args.db_path) + + # Use the jobs_list from patchwork_runner instead of creating our own + # This ensures all defined pipelines will be used + jobs = patchwork_runner.jobs_list + + log_message(f"Using {len(jobs)} jobs from patchwork_runner.py:") + for job in jobs: + log_message(f" - {job.name} (Pipeline ID: {job.azure_pipeline_id})") + + log_message("Starting monitor - Press Ctrl+C to stop") + + # No need to create tables as that's handled in patchwork_runner.py's __main__ section + + # In minutes + start_time = time.time() + end_time = start_time + + # Main monitoring loop + try: + while True: + time_interval = (end_time - start_time) / 60 + 10 + start_time = time.time() + + try: + # Routine checking message at DEBUG level to reduce noise during normal operation + log_message(f"Checking for new patches (looking back {int(time_interval)} minutes)...", LOG_DEBUG) + patch_list = patchwork_runner.fetch_and_process_patches(mydb, jobs, time_interval) + + if not patch_list: + # Use DEBUG level for the common "no patches" case + log_message("No new patches found, sleeping for 1 minute...", LOG_DEBUG) + time.sleep(60*1) + log_message("Waking up from sleep", LOG_DEBUG) + else: + # Use INFO level when we actually find patches (important event) + log_message(f"Processed {len(patch_list)} patches") + except Exception as e: + # Always use ERROR level for exceptions + log_message(f"ERROR: Exception while processing patches: {str(e)}", LOG_ERROR) + import traceback + log_message(f"Traceback: {traceback.format_exc()}", LOG_ERROR) + log_message("Continuing in 60 seconds...", LOG_WARNING) + time.sleep(60) + log_message("Resuming after error", LOG_WARNING) + + end_time = time.time() + cycle_duration = end_time - start_time + # Use DEBUG for routine cycle completion + log_message(f"Monitoring cycle completed in {cycle_duration:.2f} seconds", LOG_DEBUG) + finally: + # Clean up + log_message("Closing database connection") + mydb.close() + log_message("Monitor stopped.") + +if __name__ == "__main__": + main() diff --git a/sqlite_helper.py b/sqlite_helper.py new file mode 100644 index 0000000..331a4f7 --- /dev/null +++ b/sqlite_helper.py @@ -0,0 +1,166 @@ +import sqlite3 +import os +import time +import threading + +class SQLiteDatabase: + """ + SQLite database helper for FFmpeg Patchwork CI + Provides simplified interface for database operations + """ + + def __init__(self, db_path): + """ + Initialize the SQLite database + + Args: + db_path: Path to the SQLite database file + """ + self.db_path = db_path + self.connection = None + self._connect() + + def _connect(self): + """Create a new database connection""" + # Ensure directory exists + os.makedirs(os.path.dirname(os.path.abspath(self.db_path)), exist_ok=True) + self.connection = sqlite3.connect(self.db_path) + self.connection.row_factory = sqlite3.Row + + def get_cursor(self): + """Get a database cursor, reconnecting if necessary""" + try: + # Test connection + self.connection.execute("SELECT 1") + except (sqlite3.Error, AttributeError): + # Reconnect if connection is lost or was never established + self._connect() + + return self.connection.cursor() + + def create_missing_table(self, name, columns): + """ + Create a table if it doesn't exist + + Args: + name: Table name + columns: SQL column definitions as a string + """ + cursor = self.get_cursor() + cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name=?", (name,)) + if cursor.fetchone() is not None: + # print(f"Table {name} already exists") + return + + query = f"CREATE TABLE {name} {columns}" + # print(query) + cursor.execute(query) + self.connection.commit() + return + + def query(self, table_name, keys, filter_command=""): + """ + Execute a SELECT query and return the first matching row + + Args: + table_name: Table to query + keys: List of column names to select + filter_command: WHERE clause and other SQL filters + + Returns: + Dict containing the query results, or empty dict if no match + """ + cursor = self.get_cursor() + + str_cols = ", ".join(keys) + sql_query = f"SELECT {str_cols} FROM {table_name} {filter_command}" + # print(sql_query) + cursor.execute(sql_query) + db_out = cursor.fetchone() + out = {} + if not db_out: + return out + + for k in keys: + out[k] = db_out[k] + return out + + def query_all(self, table_name, keys, filter_command=""): + """ + Execute a SELECT query and return all matching rows + + Args: + table_name: Table to query + keys: List of column names to select + filter_command: WHERE clause and other SQL filters + + Returns: + List of dicts containing the query results + """ + cursor = self.get_cursor() + + str_cols = ", ".join(keys) + sql_query = f"SELECT {str_cols} FROM {table_name} {filter_command}" + # print(sql_query) + cursor.execute(sql_query) + db_out = cursor.fetchall() + + results = [] + for row in db_out: + out = {} + for k in keys: + out[k] = row[k] + results.append(out) + return results + + def insert(self, table, key_value_dict): + """ + Insert a new row into a table + + Args: + table: Table name + key_value_dict: Dict mapping column names to values + """ + cursor = self.get_cursor() + + keys = list(key_value_dict.keys()) + values = list(key_value_dict.values()) + + placeholders = ", ".join(["?" for _ in keys]) + keys_str = ", ".join(keys) + + sql_request = f'INSERT INTO {table} ({keys_str}) VALUES ({placeholders})' + # print(f"{sql_request} with values {values}") + cursor.execute(sql_request, values) + self.connection.commit() + return cursor.lastrowid + + def update(self, table, ref_key, ref_value, keys, values): + """ + Update existing rows in a table + + Args: + table: Table name + ref_key: List of column names to use in WHERE clause + ref_value: List of values corresponding to ref_key + keys: List of column names to update + values: List of new values corresponding to keys + """ + cursor = self.get_cursor() + + set_clauses = [f"{k} = ?" for k in keys] + where_clauses = [f"{k} = ?" for k in ref_key] + + str_set = ", ".join(set_clauses) + str_where = " AND ".join(where_clauses) + + sql_request = f'UPDATE {table} SET {str_set} WHERE {str_where}' + # print(f"{sql_request} with values {values + ref_value}") + cursor.execute(sql_request, values + ref_value) + self.connection.commit() + + def close(self): + """Close the database connection""" + if self.connection: + self.connection.close() + self.connection = None diff --git a/test_patchwork_permissions.py b/test_patchwork_permissions.py new file mode 100644 index 0000000..bb219af --- /dev/null +++ b/test_patchwork_permissions.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +""" +Test script for verifying Patchwork API permissions. +This script helps diagnose permission issues with the Patchwork API token. +""" + +import requests +import os +import sys +import argparse +import json + +def test_patchwork_token(host, token, patch_id=None): + """ + Test Patchwork API token permissions + + Args: + host: Patchwork host (e.g., patchwork.ffmpeg.org) + token: Patchwork API token + patch_id: Optional patch ID to test check creation + + Returns: + None, prints results to stdout + """ + base_url = f"https://{host}" + headers = {"Authorization": f"Token {token}"} + + print("Testing Patchwork API token permissions...") + print(f"Host: {host}") + print(f"Token: {token[:5]}...{token[-5:]}") + + # Test 1: Get API root - should always work if token is valid + print("\n1. Testing API root access...") + url = f"{base_url}/api/" + + try: + response = requests.get(url, headers=headers) + print(f"Status code: {response.status_code}") + + if response.status_code >= 200 and response.status_code < 300: + print("â API root access: SUCCESS") + try: + print(f"Available endpoints: {', '.join(response.json().keys())}") + except: + print("Could not parse endpoints") + else: + print("â API root access: FAILED") + print(f"Response: {response.text}") + print("Your token may be invalid or expired.") + return + except Exception as e: + print(f"â API root access: FAILED - Error: {str(e)}") + return + + # Test 2: List projects - read access check + print("\n2. Testing project list access (read permissions)...") + url = f"{base_url}/api/projects/" + + try: + response = requests.get(url, headers=headers) + print(f"Status code: {response.status_code}") + + if response.status_code >= 200 and response.status_code < 300: + print("â Project list access: SUCCESS") + projects = response.json() + if projects: + print(f"Found {len(projects)} projects, first project: {projects[0]['name']}") + else: + print("â Project list access: FAILED") + print(f"Response: {response.text}") + except Exception as e: + print(f"â Project list access: FAILED - Error: {str(e)}") + + # Test 3: Post a check (if patch_id provided) + if patch_id: + print(f"\n3. Testing check creation for patch ID {patch_id} (write permissions)...") + url = f"{base_url}/api/patches/{patch_id}/checks/" + + payload = { + "state": "success", + "context": "make_x86", + "description": "Testing API permissions", + "description_long": "This is a test post from the permission testing script." + } + + try: + response = requests.post(url, headers=headers, data=payload) + print(f"Status code: {response.status_code}") + + if response.status_code >= 200 and response.status_code < 300: + print("â Check creation: SUCCESS") + print("Your token has write permissions for posting check results!") + else: + print("â Check creation: FAILED") + print(f"Response: {response.text}") + + if response.status_code == 403: + print("\nPERMISSION ERROR: Your token does not have write permissions.") + print("You need to generate a new token with the appropriate scope.") + print("Visit the Patchwork web interface, go to your profile settings,") + print("and create a new API token with the 'write' permission.") + except Exception as e: + print(f"â Check creation: FAILED - Error: {str(e)}") + else: + print("\n3. Skipping check creation test (no patch ID provided)") + print("To test check creation, run this script with the --patch-id parameter.") + + # Summary + print("\nSUMMARY:") + if not patch_id: + print("Basic token validation complete. For complete testing, run with --patch-id.") + print("If you're seeing 403 errors in the main application, you likely need a token with write permissions.") + print("Make sure your token has the 'write' scope in the Patchwork web interface.") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Test Patchwork API token permissions") + parser.add_argument("--host", default=os.environ.get("PATCHWORK_HOST", "patchwork.ffmpeg.org"), + help="Patchwork host (default: from PATCHWORK_HOST env var or 'patchwork.ffmpeg.org')") + parser.add_argument("--token", default=os.environ.get("PATCHWORK_TOKEN", ""), + help="Patchwork API token (default: from PATCHWORK_TOKEN env var)") + parser.add_argument("--patch-id", type=int, help="Optional patch ID to test check creation") + + args = parser.parse_args() + + if not args.token: + print("ERROR: No token provided. Use --token or set PATCHWORK_TOKEN environment variable.") + sys.exit(1) + + test_patchwork_token(args.host, args.token, args.patch_id) commit 1b89ec19cf9c04461d03d02267103ae5419f0bd4 Author: softworkz <softwo...@hotmail.com> AuthorDate: Wed May 28 19:31:27 2025 +0200 Commit: softworkz <softwo...@hotmail.com> CommitDate: Wed May 28 19:31:27 2025 +0200 initial commit ----------------------------------------------------------------------- hooks/post-receive -- UNNAMED PROJECT
_______________________________________________ ffmpeg-cvslog mailing list ffmpeg-cvslog@ffmpeg.org https://ffmpeg.org/mailman/listinfo/ffmpeg-cvslog To unsubscribe, visit link above, or email ffmpeg-cvslog-requ...@ffmpeg.org with subject "unsubscribe".