diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 47b6c46..e59c28c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,15 +27,8 @@ repos: "90", --wrap-descriptions, "90", - prow, + pipelines_as_code_prow, ] - - repo: local - hooks: - - id: generated - language: system - name: Check Generated Files - entry: make - args: ["check"] - repo: local hooks: diff --git a/.tekton/pipelines/in-repo.yaml b/.tekton/pipelines/in-repo.yaml index c0eb0ae..29c4a18 100644 --- a/.tekton/pipelines/in-repo.yaml +++ b/.tekton/pipelines/in-repo.yaml @@ -76,6 +76,6 @@ spec: - name: PAC_LGTM_REVIEW_EVENT value: $(params.lgtm_review_event) script: | - exec python3 prow/prow.py + exec ./pipelines-as-code-prow workspaces: - name: source diff --git a/Dockerfile b/Dockerfile index 662f021..c5ff4e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ FROM registry.access.redhat.com/ubi9/ubi-minimal - RUN microdnf install python3-requests -y && \ microdnf clean all && \ rm -rf /var/cache/yum - -ADD ./prow/prow.py /prow.py - -ENTRYPOINT ["python3", "/prow.py"] +RUN mkdir /src +USER 1001 +COPY --chown=1001:1001 pipelines-as-code-prow /src +COPY --chown=1001:1001 pipelines_as_code_prow /src/pipelines_as_code_prow +ENTRYPOINT ["python3", "/src/pipelines-as-code-prow"] diff --git a/Makefile b/Makefile index 01f2995..f52e0c3 100644 --- a/Makefile +++ b/Makefile @@ -12,44 +12,26 @@ PASS_TOKEN = github/chmouel-token PRURL = https://github.com/$(GH_REPO_OWNER)/$(GH_REPO_NAME)/pull/$(GH_PR_NUM) CONTAINER_IMAGE = ghcr.io/openshift-pipelines/pipelines-as-code-prow:nightly PYTEST = uvx --with=requests --with=pytest-cov --with=pytest-sugar pytest -PYTEST_ARGS = --cov=prow --cov-report=term +PYTEST_ARGS = --cov=pipelines_as_code_prow --cov-report=term # Phony targets -.PHONY: generate test directtest open_pr check help - -# Targets with help messages - -generate: ## Generate pipeline YAML files - @echo "Generating $(PIPELINE_PROW) 🚿" && \ - echo "####### Do not edit this file, use 'make generate'" > $(PIPELINE_PROW) && \ - $(UVCMD) ./hack/tekton-task-embed-script.py --using-image=$(CONTAINER_IMAGE) ./prow/base.yaml >> $(PIPELINE_PROW) +.PHONY: test directtest open_pr help test: ## Run tests with pytest - @$(PYTEST) -v prow $(PYTEST_ARGS) $(ARGS) + @$(PYTEST) -v pipelines_as_code_prow $(PYTEST_ARGS) $(ARGS) directtest: ## Run a specific command directly (e.g., make directtest CMD=/lgtm) @[[ -n "$(CMD)" ]] || (echo "Please specify a command to run as argument: like 'make directtest CMD=/lgtm'" && exit 1) @env GH_PR_NUM=$(GH_PR_NUM) GH_REPO_NAME=$(GH_REPO_NAME) GH_REPO_OWNER=$(GH_REPO_OWNER) \ GH_PR_SENDER=$(GH_PR_SENDER) GH_COMMENT_SENDER=$(GH_COMMENT_SENDER) \ PAC_TRIGGER_COMMENT="$(CMD)" GITHUB_TOKEN=`pass show $(PASS_TOKEN)` \ - ./prow/prow.py + ./pipelines_as_code_prow/prow.py open_pr: ## Open the PR in the browser @if type -p xdg-open > /dev/null; then xdg-open $(PRURL); \ elif type -p open > /dev/null; then open $(PRURL); \ else echo "No supported browser opener found (xdg-open or open)"; fi -check: ## Check if the generated prow files are up to date - @tmpfile=$$(mktemp /tmp/check-XXXXXX); \ - make -s generate PIPELINE_PROW=$$tmpfile && \ - if ! cmp -s $(PIPELINE_PROW) $$tmpfile; then \ - echo "Changes are needed in $(PIPELINE_PROW): make sure to modify the prun in prow/ and run 'make generate' before pushing"; \ - diff --color=always -u $(PIPELINE_PROW) $$tmpfile; \ - rm -f $$tmpfile $$tmpfile2; \ - exit 1; \ - fi; \ - rm -f $$tmpfile - help: ## Display this help message @python3 -c "import re, sys; \ targets = [m.groups() for m in re.finditer(r'^([a-zA-Z0-9_-]+):.*?## (.*)$$', sys.stdin.read(), re.MULTILINE)]; \ diff --git a/README.md b/README.md index 3582c34..7448874 100644 --- a/README.md +++ b/README.md @@ -97,13 +97,6 @@ project. You will need to install [uv](https://github.com/astral-sh/uv) and use the makefile targets. -This project use generated PipelineRun as described -[here](https://blog.chmouel.com/2020/07/28/tekton-yaml-templates-and-script-feature/), -you will need to edit the files in the [prow](./prow) directory and run `make` to regenerate -the main PipelineRun. There is a script that inline it in the [pipeline-prow.yaml](./pipeline-prow.yaml). -That allows to separate the logic from the main PipelineRun and be able to use -your editor properly when editing the code (with linters, lsp, checkers etc..). - Please install the pre-commit hooks by running `pre-commit install` to make sure your commits include the necessary files. diff --git a/hack/tekton-task-embed-script.py b/hack/tekton-task-embed-script.py deleted file mode 100644 index 904bb49..0000000 --- a/hack/tekton-task-embed-script.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright 2025 Chmouel Boudjnah -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# Launch with uv run or install the dependency by other mean -# -# /// script -# requires-python = ">=3.12" -# dependencies = [ -# "ruamel.yaml", -# ] -# /// -import argparse -import io -import os.path -import re -import sys -import typing - -from ruamel.yaml import YAML - -REGEXP = r"^#include\s*([^$]*)" - - -def replace(args: argparse.Namespace) -> typing.List: - yaml = YAML() - with open(args.yaml_file, encoding="utf-8") as fp: - docs = yaml.load_all(fp) - rets = [] - for yamlDoc in docs: - if "spec" not in yamlDoc and "tasks" not in yamlDoc["spec"]: - continue - - if "tasks" in yamlDoc["spec"]: - # TODO: handle multiple - steps = yamlDoc["spec"]["tasks"][0]["taskSpec"]["steps"] - else: - print(f"ERROR: no steps found in task: {yamlDoc['metadata']['name']}") - print(f"available keys: {list(yamlDoc['spec'].keys())}") - return [] - - if args.using_image: - yamlDoc["spec"]["tasks"][0]["taskSpec"]["steps"][0]["image"] = ( - args.using_image - ) - del yamlDoc["spec"]["tasks"][0]["taskSpec"]["steps"][0]["script"] - else: - for task in steps: - if "script" not in task: - continue - if not task["script"].startswith("#include "): - continue - match = re.match(REGEXP, task["script"]) - if not match: - continue - filename = match[1].strip() - rpath = os.path.join( - os.path.dirname(os.path.abspath(args.yaml_file)), filename - ) - if os.path.exists(rpath): - filename = rpath - if not os.path.exists(filename): - sys.stderr.write( - f"WARNING: we could not find a file called: {filename} in task: {yamlDoc['metadata']['name']} step: {task['name']}" - ) - continue - with open(filename, encoding="utf-8") as fp: - task["script"] = fp.read() - output = io.StringIO() - yaml.dump(yamlDoc, output) - rets.append(output.getvalue()) - return rets - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Manage your embedded Tekton script task externally" - ) - parser.add_argument("--using-image", "-i", help="Using image to run the script") - parser.add_argument("yaml_file", help="Yaml file to parse") - return parser.parse_args() - - -def main() -> str: - ret = [] - args = parse_args() - replaced = replace(args) - for doc in replaced: - if not doc or not doc.strip(): - continue - ret.append("---") - ret.append(doc.strip()) - return "\n".join(ret) - - -if __name__ == "__main__": - print(main()) diff --git a/pipeline-prow.yaml b/pipeline-prow.yaml index da0b9ca..fbded50 100644 --- a/pipeline-prow.yaml +++ b/pipeline-prow.yaml @@ -1,4 +1,3 @@ -####### Do not edit this file, use 'make generate' --- apiVersion: tekton.dev/v1 kind: Pipeline @@ -7,57 +6,57 @@ metadata: annotations: pipelinesascode.tekton.dev/on-comment: | ^/(help|rebase|merge|lgtm|(assign|unassign|label|unlabel)[ ].*)$ - pipelinesascode.tekton.dev/max-keep-runs: '5' + pipelinesascode.tekton.dev/max-keep-runs: "5" spec: params: - - name: repo_owner - - name: repo_name - - name: pull_request_number - - name: pull_request_sender - - name: comment_sender - - name: git_auth_secret - - name: git_auth_secret_key - default: git-provider-token - - name: trigger_comment - - name: lgtm_permissions - default: admin,write - - name: lgtm_threshold - default: '1' - - name: lgtm_review_event - default: APPROVE - - name: merge_method - default: rebase + - name: repo_owner + - name: repo_name + - name: pull_request_number + - name: pull_request_sender + - name: comment_sender + - name: git_auth_secret + - name: git_auth_secret_key + default: git-provider-token + - name: trigger_comment + - name: lgtm_permissions + default: admin,write + - name: lgtm_threshold + default: "1" + - name: lgtm_review_event + default: APPROVE + - name: merge_method + default: rebase tasks: - - name: manage-pr - displayName: Manage PR Assignments & Labels - taskSpec: - steps: - - name: manage-pr - image: ghcr.io/openshift-pipelines/pipelines-as-code-prow:nightly - env: - - name: GITHUB_TOKEN - valueFrom: - secretKeyRef: - name: $(params.git_auth_secret) - key: $(params.git_auth_secret_key) - - name: GH_REPO_OWNER - value: $(params.repo_owner) - - name: GH_PR_SENDER - value: $(params.pull_request_sender) - - name: GH_REPO_NAME - value: $(params.repo_name) - - name: GH_PR_NUM - value: $(params.pull_request_number) - - name: GH_COMMENT_SENDER - value: $(params.comment_sender) - - name: PAC_TRIGGER_COMMENT - value: | - $(params.trigger_comment) - - name: GH_MERGE_METHOD - value: $(params.merge_method) - - name: PAC_LGTM_THRESHOLD - value: $(params.lgtm_threshold) - - name: PAC_LGTM_PERMISSIONS - value: $(params.lgtm_permissions) - - name: PAC_LGTM_REVIEW_EVENT - value: $(params.lgtm_review_event) + - name: manage-pr + displayName: Manage PR Assignments & Labels + taskSpec: + steps: + - name: manage-pr + image: ghcr.io/openshift-pipelines/pipelines-as-code-prow:nightly + env: + - name: GITHUB_TOKEN + valueFrom: + secretKeyRef: + name: $(params.git_auth_secret) + key: $(params.git_auth_secret_key) + - name: GH_REPO_OWNER + value: $(params.repo_owner) + - name: GH_PR_SENDER + value: $(params.pull_request_sender) + - name: GH_REPO_NAME + value: $(params.repo_name) + - name: GH_PR_NUM + value: $(params.pull_request_number) + - name: GH_COMMENT_SENDER + value: $(params.comment_sender) + - name: PAC_TRIGGER_COMMENT + value: | + $(params.trigger_comment) + - name: GH_MERGE_METHOD + value: $(params.merge_method) + - name: PAC_LGTM_THRESHOLD + value: $(params.lgtm_threshold) + - name: PAC_LGTM_PERMISSIONS + value: $(params.lgtm_permissions) + - name: PAC_LGTM_REVIEW_EVENT + value: $(params.lgtm_review_event) diff --git a/pipelines-as-code-prow b/pipelines-as-code-prow new file mode 100755 index 0000000..4dc5afc --- /dev/null +++ b/pipelines-as-code-prow @@ -0,0 +1,7 @@ +#!/usr/bin/env python +# Author: Chmouel Boudjnah + +from pipelines_as_code_prow import prow + +if __name__ == "__main__": + prow.main() diff --git a/prow/__init__.py b/pipelines_as_code_prow/__init__.py similarity index 100% rename from prow/__init__.py rename to pipelines_as_code_prow/__init__.py diff --git a/prow/base.yaml b/pipelines_as_code_prow/base.yaml similarity index 100% rename from prow/base.yaml rename to pipelines_as_code_prow/base.yaml diff --git a/pipelines_as_code_prow/messages.py b/pipelines_as_code_prow/messages.py new file mode 100644 index 0000000..e4c2212 --- /dev/null +++ b/pipelines_as_code_prow/messages.py @@ -0,0 +1,144 @@ +import os + +LGTM_THRESHOLD = int(os.getenv("PAC_LGTM_THRESHOLD", "1")) + +HELP_TEXT = f""" +### 🤖 Available Commands +| Command | Description | +| --------------------------- | ------------------------------------------------------------------------------- | +| `/assign user1 user2` | Assigns users for review to the PR | +| `/unassign user1 user2` | Removes assigned users | +| `/label bug feature` | Adds labels to the PR | +| `/unlabel bug feature` | Removes labels from the PR | +| `/lgtm` | Approves the PR if at least {LGTM_THRESHOLD} org members have commented `/lgtm` | +| `/merge` | Merges the PR if it has enough `/lgtm` approvals | +| `/cherry-pick target-branch`| Cherry-picks the PR changes to the target branch | +| `/rebase` | Rebases the PR branch on the base branch | +| `/help` | Shows this help message | +""" + +APPROVED_TEMPLATE = """ +### ✅ Pull Request Approved + +**Approval Status:** +* Required Approvals: {threshold} +* Current Approvals: {valid_votes} + +### 👥 Approved By: +| Reviewer | Permission | Status | +|----------|------------|--------| +{users_table} + +### 📝 Next Steps +* All required checks must pass +* Branch protection rules apply +* Get a maintainer to use the `/merge` command to merge the PR + +Thank you for your contributions! 🎉 +""" + +LGTM_BREAKDOWN_TEMPLATE = """ +### LGTM Vote Breakdown + +* **Current valid votes:** {valid_votes}/{threshold} +* **Voting required for approval:** {threshold} + +**Votes Summary:** +| Reviewer | Permission | Valid Vote | +|----------|------------|------------| +{users_table} + +""" + +SUCCESS_MERGED = """ +### ✅ PR Successfully Merged + +* Merge method: `{merge_method}` +* Merged by: **@{comment_sender}** +* Total approvals: **{valid_votes}/{lgtm_threshold}** + +**Approvals Summary:** +| Reviewer | Permission | Status | +|----------|------------|--------| +{users_table} +""" + +# Error and status message templates +PERMISSION_CHECK_ERROR = """ +### ⚠️ Permission Check Failed + +Unable to verify permissions for user **@{user}** +* API Response Status: `{status_code}` +* This might be due to: + * User not being a repository collaborator + * Invalid authentication + * Rate limiting + +Please check user permissions and try again. +""" + +PERMISSION_DATA_MISSING = """ +### ❌ Permission Data Missing + +Failed to retrieve permission level for user **@{user}** +* Received empty permission data from GitHub API +* This might indicate an API response format change +* Please contact repository administrators for assistance +""" + +COMMENTS_FETCH_ERROR = """ +### 🚫 Failed to Retrieve PR Comments + +Unable to process LGTM votes due to API error: +* Status Code: `{status_code}` +* Response: `{response_text}` + +**Troubleshooting Steps:** +1. Check your authentication token +2. Verify PR number: `{pr_num}` +3. Ensure the PR hasn't been closed or deleted +""" + +SELF_APPROVAL_ERROR = """ +### ⚠️ Invalid LGTM Vote + +* User **@{user}** attempted to approve their own PR +* Self-approval is not permitted for security reasons +* Please [delete the comment]({comment_url}) before continuing. + +Please wait for reviews from other team members. +""" + +INSUFFICIENT_PERMISSIONS = """ +### 🔒 Insufficient Permissions + +* User **@{user}** does not have permission to merge +* Current permission level: `{permission}` +* Required permissions: `{required_permissions}` + +Please request assistance from a repository maintainer. +""" + +NOT_ENOUGH_LGTM = """ +### ❌ Insufficient Approvals + +* Current valid LGTM votes: **{valid_votes}** +* Required votes: **{threshold}** + +Please obtain additional approvals before merging. +""" + +MERGE_FAILED = """ +### ❌ Merge Failed + +Unable to merge PR #{pr_num}: +* Status Code: `{status_code}` +* Error: `{error_text}` + +**Possible causes:** +* Branch protection rules not satisfied +* Merge conflicts present +* Required checks failing + +Please resolve any issues and try again. +""" diff --git a/prow/prow.py b/pipelines_as_code_prow/prow.py old mode 100755 new mode 100644 similarity index 81% rename from prow/prow.py rename to pipelines_as_code_prow/prow.py index 6ea243a..a59284d --- a/prow/prow.py +++ b/pipelines_as_code_prow/prow.py @@ -16,147 +16,19 @@ import requests # type: ignore -LGTM_THRESHOLD = int(os.getenv("PAC_LGTM_THRESHOLD", "1")) - -# Error and status message templates -PERMISSION_CHECK_ERROR = """ -### ⚠️ Permission Check Failed - -Unable to verify permissions for user **@{user}** -* API Response Status: `{status_code}` -* This might be due to: - * User not being a repository collaborator - * Invalid authentication - * Rate limiting - -Please check user permissions and try again. -""" - -PERMISSION_DATA_MISSING = """ -### ❌ Permission Data Missing - -Failed to retrieve permission level for user **@{user}** -* Received empty permission data from GitHub API -* This might indicate an API response format change -* Please contact repository administrators for assistance -""" - -COMMENTS_FETCH_ERROR = """ -### 🚫 Failed to Retrieve PR Comments - -Unable to process LGTM votes due to API error: -* Status Code: `{status_code}` -* Response: `{response_text}` - -**Troubleshooting Steps:** -1. Check your authentication token -2. Verify PR number: `{pr_num}` -3. Ensure the PR hasn't been closed or deleted -""" - -SELF_APPROVAL_ERROR = """ -### ⚠️ Invalid LGTM Vote - -* User **@{user}** attempted to approve their own PR -* Self-approval is not permitted for security reasons -* Please [delete the comment]({comment_url}) before continuing. - -Please wait for reviews from other team members. -""" - -INSUFFICIENT_PERMISSIONS = """ -### 🔒 Insufficient Permissions - -* User **@{user}** does not have permission to merge -* Current permission level: `{permission}` -* Required permissions: `{required_permissions}` - -Please request assistance from a repository maintainer. -""" - -NOT_ENOUGH_LGTM = """ -### ❌ Insufficient Approvals - -* Current valid LGTM votes: **{valid_votes}** -* Required votes: **{threshold}** - -Please obtain additional approvals before merging. -""" - -MERGE_FAILED = """ -### ❌ Merge Failed - -Unable to merge PR #{pr_num}: -* Status Code: `{status_code}` -* Error: `{error_text}` - -**Possible causes:** -* Branch protection rules not satisfied -* Merge conflicts present -* Required checks failing - -Please resolve any issues and try again. -""" - -HELP_TEXT = f""" -### 🤖 Available Commands -| Command | Description | -| --------------------------- | ------------------------------------------------------------------------------- | -| `/assign user1 user2` | Assigns users for review to the PR | -| `/unassign user1 user2` | Removes assigned users | -| `/label bug feature` | Adds labels to the PR | -| `/unlabel bug feature` | Removes labels from the PR | -| `/lgtm` | Approves the PR if at least {LGTM_THRESHOLD} org members have commented `/lgtm` | -| `/merge` | Merges the PR if it has enough `/lgtm` approvals | -| `/rebase` | Rebases the PR branch on the base branch | -| `/help` | Shows this help message | -""" - -APPROVED_TEMPLATE = """ -### ✅ Pull Request Approved - -**Approval Status:** -* Required Approvals: {threshold} -* Current Approvals: {valid_votes} - -### 👥 Approved By: -| Reviewer | Permission | Status | -|----------|------------|--------| -{users_table} - -### 📝 Next Steps -* All required checks must pass -* Branch protection rules apply -* Get a maintainer to use the `/merge` command to merge the PR - -Thank you for your contributions! 🎉 -""" - -LGTM_BREAKDOWN_TEMPLATE = """ -### LGTM Vote Breakdown - -* **Current valid votes:** {valid_votes}/{threshold} -* **Voting required for approval:** {threshold} - -**Votes Summary:** -| Reviewer | Permission | Valid Vote | -|----------|------------|------------| -{users_table} - -""" - -SUCCESS_MERGED = """ -### ✅ PR Successfully Merged - -* Merge method: `{merge_method}` -* Merged by: **@{comment_sender}** -* Total approvals: **{valid_votes}/{lgtm_threshold}** - -**Approvals Summary:** -| Reviewer | Permission | Status | -|----------|------------|--------| -{users_table} -""" +from .messages import ( # isort:skip + APPROVED_TEMPLATE, + COMMENTS_FETCH_ERROR, + HELP_TEXT, + INSUFFICIENT_PERMISSIONS, + LGTM_BREAKDOWN_TEMPLATE, + MERGE_FAILED, + NOT_ENOUGH_LGTM, + PERMISSION_CHECK_ERROR, + PERMISSION_DATA_MISSING, + SELF_APPROVAL_ERROR, + SUCCESS_MERGED, +) class GitHubAPI: diff --git a/prow/test_prow.py b/pipelines_as_code_prow/test_prow.py similarity index 99% rename from prow/test_prow.py rename to pipelines_as_code_prow/test_prow.py index 572b82b..7544b2e 100644 --- a/prow/test_prow.py +++ b/pipelines_as_code_prow/test_prow.py @@ -3,7 +3,7 @@ import pytest -from prow.prow import GitHubAPI, PRHandler +from .prow import GitHubAPI, PRHandler class MyFakeResponse: diff --git a/pyproject.toml b/pyproject.toml index 5f66909..486015d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,4 @@ readme = "README.md" requires-python = ">=3.12" [dependency-groups] -dev = [ - "pytest", -] +dev = ["pytest"]