diff --git a/.github/workflows/dangerjs.yml b/.github/workflows/dangerjs.yml new file mode 100644 index 0000000..4d2b897 --- /dev/null +++ b/.github/workflows/dangerjs.yml @@ -0,0 +1,24 @@ +name: DangerJS Pull Request linter +on: + pull_request_target: + types: [opened, edited, reopened, synchronize] + +permissions: + pull-requests: write + contents: write + +jobs: + pull-request-style-linter: + runs-on: ubuntu-latest + steps: + - name: Check out PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: DangerJS pull request linter + uses: espressif/shared-github-dangerjs@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + instructions-contributions-file: 'CONTRIBUTING.md' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 929dc78..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: Lint code in the repo - -on: - push: - -jobs: - shellcheck: - name: Shellcheck - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Run ShellCheck - uses: ludeeus/action-shellcheck@master diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..6b2c3c0 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,16 @@ +name: Check pre-commit + +on: + pull_request: + +jobs: + check-pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + - uses: pre-commit/action@v3.0.1 + env: + SKIP: pip-compile diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml new file mode 100644 index 0000000..be57119 --- /dev/null +++ b/.github/workflows/python-test.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + pull_request: + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-cov + pip install -r requirements.txt + + - name: Run pytest with coverage + run: | + python -m pytest diff --git a/.github/workflows/test_jira_sync.yml b/.github/workflows/test_jira_sync.yml deleted file mode 100644 index ebd9b4b..0000000 --- a/.github/workflows/test_jira_sync.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Run JIRA Sync Unit Tests - -on: [push] - -jobs: - test_jira_sync: - name: test_jira_sync - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Test JIRA sync - uses: ./sync_issues_to_jira - with: - entrypoint: ./sync_issues_to_jira/test_sync_to_jira.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6c52ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,109 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# cache +.mypy_cache/ +.ruff_cache/ +__pycache__/* + +# Generated by setuptools-scm when using pip install -e +version.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c2e52ab --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,69 @@ +# Run `pre-commit autoupdate` to update to the latest pre-commit hooks version. +# When changing the version of tools that are also installed as development dependencies (e.g., black, mypy, ruff), +# please ensure the same versions are pinned in this file as in `pyproject.toml`. +--- +minimum_pre_commit_version: 3.3.0 # Specifies the minimum version of pre-commit required for this configuration +default_install_hook_types: [pre-commit, commit-msg] # Default hook types to install if not specified in individual hooks +default_stages: [pre-commit] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace # Removes trailing whitespaces from lines + - id: end-of-file-fixer # Ensures files end with a newline + - id: check-executables-have-shebangs # Checks executables have a proper shebang + - id: mixed-line-ending # Detects mixed line endings (CRLF/LF) + args: ['-f=lf'] # Forces files to use LF line endings + - id: double-quote-string-fixer # Converts single quotes to double quotes in strings + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.2.1 + hooks: + - id: ruff # Runs ruff linter (replaces flake8) + args: [--fix, --exit-non-zero-on-fix] + + - repo: https://github.com/asottile/reorder_python_imports + rev: v3.12.0 + hooks: + - id: reorder-python-imports # Reorders Python imports to a standard format (replaces isort) + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy # Runs mypy for Python type checking + additional_dependencies: ['types-all'] + + - repo: https://github.com/espressif/conventional-precommit-linter + rev: v1.6.0 + hooks: + - id: conventional-precommit-linter # Lints commit messages for conventional format + stages: [commit-msg] + + - repo: https://github.com/psf/black + rev: '24.1.1' + hooks: + - id: black # Formats Python code using black + + - repo: https://github.com/pylint-dev/pylint + rev: v3.0.3 + hooks: + - id: pylint # Runs pylint on Python code + + - repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell # Code spell checker + args: ["--write-changes"] + additional_dependencies: [tomli] + + + # Local hooks + - repo: local + hooks: + - id: pip-compile + name: compile requirements.txt + entry: bash -c 'pip-compile --output-file=requirements.txt pyproject.toml > /dev/null' + language: system + pass_filenames: false + files: requirements.txt|pyproject.toml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c0ae237 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,79 @@ +We welcome contributions! To contribute to this repository, please read these instructions: + +- [Project Organization](#project-organization) +- [Code and Testing](#code-and-testing) +- [Documentation and Maintenance](#documentation-and-maintenance) +- [Development and Local Testing](#development-and-local-testing) + +--- + +## Project Organization + +- **Project Configuration:** The setup for both production and development dependencies is outlined in the `pyproject.toml` file. This centralized approach simplifies dependency management. + +- **Automatic `requirements.txt`:** Changes to dependencies are automatically reflected in the `requirements.txt` file, derived from `pyproject.toml`. Direct modifications should be avoided; instead, update `pyproject.toml`. + +- **Commit Standard:** Adherence to the Espressif standard for Conventional Commits ensures consistency in commit messages. Tools like pre-commit hooks and the integrated PR linter DangerJS assist in formatting commit messages appropriately, a key factor for `commitizen` to auto-generate the changelog. + +## Code and Testing + +- **Code Style and Structure:** + + - **Pre-Commit Hooks:** Install pre-commit hooks in this repository using the `pre-commit install` command. + + - **Readable Code Structure:** Structure your code in a readable manner. The main logic should be in the default rule function, with implementation details in helper functions. Avoid nested `if` statements and unnecessary `else` statements to maintain code clarity and simplicity. + + - **Remove Debug Statements:** Remove any development debug statements from your files. + +- **Automated Tests:** The tests should cover all typical usage scenarios as well as edge cases to ensure robustness. + +- **Testing Tool:** It is recommended to run `pytest` frequently during development to ensure that all aspects of your code are functioning as expected. + +## Documentation and Maintenance + +- **Changelog:** `CHANGELOG.md` is generated automatically by `commitizen` from commit messages. Not need to update `CHANGELOG.md` manually. Focus on informative and clear commit messages which end in the release notes. + +- **Documentation:** Regularly check and update the documentation to keep it current. + +- **PR Descriptions and Documentation:** When contributing, describe all changes or new features in the PR (Pull Request) description as well as in the documentation. When changing the style to the output style, attach a thumbnail after the change. + +## Development and Local Testing + +1. **Clone the Project** + +- Clone the repository to your local machine using: + + ```sh + git clone + ``` + +2. **Set Up Development Environment:** + +- Create and activate a virtual environment: + + ```sh + python -m venv venv && source ./venv/bin/activate + ``` + + or: + + ```sh + virtualenv venv && source ./venv/bin/activate + ``` + +- Install the project and development dependencies: + ```sh + pip install -e '.[dev]' + ``` + +3. **Testing Your Changes:** + +- Before submitting a pull request, ensure your changes pass all the tests. You can run the test suite with the following command: + +```sh +pytest +``` + +--- + +👏**Thank you for your contributions.** diff --git a/Dockerfile b/Dockerfile index eeedf96..ff3e9a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,35 @@ -FROM node:current-bullseye-slim +FROM node:20-bookworm-slim +# Setting environment variables ENV LANG C.UTF-8 ENV LC_ALL C.UTF-8 -ADD requirements.txt /tmp/requirements.txt +# Install Python, venv, and other essentials +RUN : \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + python3.11 \ + python3.11-venv \ + python3-pip \ + python3-setuptools \ + && rm -rf /var/lib/apt/lists/* -RUN apt-get update \ - && apt-get install -y python3-pip \ - && pip3 install --upgrade pip \ - && pip3 install -r /tmp/requirements.txt +# Enable virtual environment for subsequent commands (safe for Debian BookWorm) +RUN python3.11 -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" -RUN rm /tmp/requirements.txt +# Upgrade pip +RUN pip install --no-cache-dir --upgrade pip +# Install Python dependencies +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +# Install Node.js dependencies RUN npm i -g @shogobg/markdown2confluence@0.1.6 -ADD sync_issue.py /sync_issue.py -ADD sync_pr.py /sync_pr.py -ADD sync_to_jira.py /sync_to_jira.py -ADD test_sync_to_jira.py /test_sync_to_jira.py +# Copy Python scripts +COPY sync_jira_actions/ /sync_jira_actions -ENTRYPOINT ["/usr/bin/python3", "/sync_to_jira.py"] +# Define the entrypoint to use the virtual environment's Python interpreter +ENTRYPOINT ["/opt/venv/bin/python", "/sync_jira_actions/sync_to_jira.py"] diff --git a/README.md b/README.md index b4b0194..ef6f93a 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,201 @@ -# GitHub to JIRA Issue Sync +
+

GitHub to JIRA Sync (GitHub Action)

+ GitHub to JIRA Sync logo +
+
+ + release + tests + codeql +
+GitHub to JIRA Sync GitHub Action is a solution for one-way synchronization of GitHub issues into Espressif JIRA projects. +
+
+This action automates the integration of your GitHub repositories with JIRA projects by automatically creating corresponding JIRA tickets for new GitHub issues and pull requests, as well as managing comments within these issues and pull requests from external contributors. -This is a GitHub action that performs simple one way syncing of GitHub issues into JIRA. +
-- When a new GitHub issue is opened - - A corresponding JIRA issue (in the configured JIRA project) is created. - - Markdown in the GitHub issue body is converted into JIRA Wiki format (thanks to [markdown2confluence](http://chunpu.github.io/markdown2confluence/browser/)) - - A JIRA custom field "GitHub Reference" is set to the URL of the issue - - The GitHub issue title has `(JIRA-KEY)` appended to it. -- When a GitHub issue is edited, the summary and description of the JIRA issue are updated. -- When comments are made on the GitHub issue, a comment is created on the JIRA issue. -- When GitHub comments are edited or deleted a comment is created on the JIRA issue. -- When the GitHub issue is closed or deleted a comment is created on the JIRA issue. -- When labels are added or removed from the GitHub issue, the same label is added or removed from the JIRA issue. +- [Features](#features) +- ['Synced From' Link Details](#synced-from-link-details) + - [Key Features and Considerations](#key-features-and-considerations) +- [Manually Linking a GitHub Issue to JIRA](#manually-linking-a-github-issue-to-jira) + - [Step-by-Step Guide](#step-by-step-guide) + - [Automation Trigger](#automation-trigger) + - [Important Note](#important-note) +- [Issue Type Synchronization](#issue-type-synchronization) + - [How It Works](#how-it-works) +- [Limitations](#limitations) + - [What's Not Synced](#whats-not-synced) +- [Usage Instructions for GitHub to JIRA Issue Sync Action](#usage-instructions-for-github-to-jira-issue-sync-action) + - [Syncing New Issues to JIRA](#syncing-new-issues-to-jira) + - [Syncing New Issue Comments to JIRA](#syncing-new-issue-comments-to-jira) + - [Syncing New Pull Requests to JIRA](#syncing-new-pull-requests-to-jira) +- [Manually Syncing Issues and Pull Requests to JIRA](#manually-syncing-issues-and-pull-requests-to-jira) + - [Configuration for Manual Sync](#configuration-for-manual-sync) + - [Workflow Setup](#workflow-setup) +- [Environment Variables and Secrets Configuration](#environment-variables-and-secrets-configuration) + - [Important Consideration:](#important-consideration) +- [Project Issues](#project-issues) +- [Contributing](#contributing) -# 'Synced From' Link +## Features -After a synced JIRA issue is created, the action creates a [Remote Issue Link](https://developer.atlassian.com/server/jira/platform/creating-remote-issue-links/) on the JIRA issue, where the "[globalID](https://developer.atlassian.com/server/jira/platform/using-fields-in-remote-issue-links/#globalid)" is the GitHub issue URL. +- **Automatic Issue Creation**: When a new GitHub issue is opened, a matching JIRA issue is created within the specified project. +- **Markdown Conversion**: The body of the GitHub issue is converted to JIRA Wiki format using [markdown2confluence](http://chunpu.github.io/markdown2confluence/browser/). +- **Custom Field Mapping**: A JIRA custom field named "GitHub Reference" is populated with the URL of the GitHub issue. +- **Issue Title Sync**: The title of the GitHub issue is updated to include the JIRA issue key. +- **Bi-directional Comment Sync**: Comments added to a GitHub issue are mirrored in the corresponding JIRA issue. Edits and deletions are also reflected. +- **Label Synchronization**: Labels added or removed from the GitHub issue are similarly updated in the JIRA issue. +- **Remote Issue Link**: After syncing, a [Remote Issue Link](https://developer.atlassian.com/server/jira/platform/creating-remote-issue-links/) is created on the JIRA issue for easy reference back to the GitHub issue. -This remote issue link is used to find existing synced issues when changes happen. +## 'Synced From' Link Details -The sync action will continue to update JIRA issues which are moved to other JIRA projects, provided the remote issue link is moved and the Github Action's JIRA user can see the new project. +Once a JIRA issue is created and synced from GitHub, a [Remote Issue Link](https://developer.atlassian.com/server/jira/platform/creating-remote-issue-links/) is automatically generated for the JIRA issue. This link includes a "globalID" that corresponds to the URL of the GitHub issue. This mechanism ensures that any future changes to the GitHub issue are tracked and reflected in the JIRA issue, maintaining a consistent link between the two platforms. -To break a link between a GitHub issue and a JIRA issue, delete the Remote Issue Link. (Note that if the GitHub Issue is updated later on, this action may create a new JIRA issue to track it.) +### Key Features and Considerations -Note that manually created Remote Issue Links to GitHub issues will not have the globalID set, so they won't work (JIRA doesn't give a way to search for Remote Issue Links by URL, only by globalID, so there's no automated solution to this problem.) +- **Persistent Synchronization**: The Remote Issue Link facilitates ongoing updates to JIRA issues that are moved to other JIRA projects, assuming the remote issue link is also transferred and the GitHub Action's JIRA user has access to the new project. +- **Link Management**: To sever the connection between a GitHub issue and a JIRA issue, simply remove the Remote Issue Link. Be aware, however, that subsequent updates to the GitHub issue may trigger the creation of a new JIRA issue to ensure continuity of tracking. +- **Manual Links**: It's important to note that Remote Issue Links created manually for GitHub issues won't contain the necessary globalID. Since JIRA's search functionality for Remote Issue Links relies exclusively on globalID and not the URL, such manually created links cannot facilitate automated syncing. -# Manually Linking a GitHub Issue +This design ensures that the integration between GitHub and JIRA remains dynamic and adaptable to changes, providing a robust solution for tracking issues across both platforms. -It's not possible to create a Remote Issue Link with the correct `globalID` without using the JIRA API. Instead, to manually connect an existing GitHub issue with a JIRA issue in the Web UI: +## Manually Linking a GitHub Issue to JIRA -1. Check that no other JIRA issue is syncing this GitHub issue (advanced search for `issue in issuesWithRemoteLinksByGlobalId("GitHub Issue URL")`). -2. Put the URL of the GitHub issue somewhere in the JIRA issue description. -3. Put the JIRA issue key at the end of the GitHub issue title, in parentheses. Like this: `GitHub Issue title (JIRAKEY-123)` +Creating a Remote Issue Link with the appropriate `globalID` directly through the JIRA Web UI is not feasible without leveraging the JIRA API. However, you can manually establish a connection between an existing GitHub issue and a JIRA issue by following these steps: -The GitHub action will create the "Synced from" link the next time this issue is updated (probably immediately, if you did the steps in the written order). +### Step-by-Step Guide -Important: If the URL of the GitHub issue is not found in the JIRA issue description, nothing will happen (this is to prevent external parties from making unintended updates to JIRA issues.) +1. **Verify Unique Linking**: Ensure that the GitHub issue is not already linked to another JIRA issue. Use JIRA's advanced search with the query `issue in issuesWithRemoteLinksByGlobalId("GitHub Issue URL")` to check for existing links. +2. **Update JIRA Issue Description**: Include the URL of the GitHub issue in the description field of the JIRA issue. This step is crucial for the GitHub action to recognize and link the issues. +3. **Amend GitHub Issue Title**: Append the JIRA issue key to the end of the GitHub issue title within parentheses, e.g., `GitHub Issue title (JIRAKEY-123)`. This modification helps in identifying the linked issues easily. -# Issue Types +### Automation Trigger -If a new GitHub issue has any labels where the name of the label matches the name of an issue type, or the name of the label matches `Type: `, then the JIRA issue will be created with that issue type. Matching is case insensitive. +Upon the next update to the GitHub issue (which might occur immediately if you follow the steps sequentially), the GitHub action will automatically generate the "Synced from" link, establishing a manual link between the issues. -If no labels match issue types, environment variable `JIRA_ISSUE_TYPE` is used as the type for new issues. If `JIRA_ISSUE_TYPE` is not set, the default new issue type is "Task". +### Important Note -Changing labels on a GitHub issue does not change the issue type, because [JIRA REST API currently cannot safely change an issue type to one with a different workflow](https://jira.atlassian.com/browse/JRACLOUD-68207). Instead, an issue comment is left in JIRA. +If the GitHub issue URL is not present in the JIRA issue description, the GitHub action will not create a link. This safeguard is designed to prevent unauthorized or unintended updates to JIRA issues from external sources. -# Limitations +## Issue Type Synchronization -Currently does not sync the following things: +The GitHub to JIRA Issue Sync Action intelligently creates JIRA issues with specific types based on the labels attached to the GitHub issue. This feature ensures that the issue types in JIRA accurately reflect the nature or category of the issue as determined in GitHub. -- Labels, apart from any which match Issue Types -- Transitions. Closing, Reopening or Deleting an issue in GitHub only leaves a comment in the JIRA issue. This is at least partially by design because sometimes GitHub issues are closed by their reporters even though an underlying issue still needs fixing in the codebase. +### How It Works -# Usage +- **Label Matching**: When a new GitHub issue is created, the action checks for labels that either directly match the name of a JIRA issue type or follow the format `Type: `. The search for matching labels is case insensitive, ensuring flexibility in label naming conventions. +- **Environment Variable Fallback**: In cases where no labels match any issue type, the action refers to the `JIRA_ISSUE_TYPE` environment variable to determine the issue type for the new JIRA issue. If this environment variable is not defined, the default issue type used is "Task". +- **Handling Label Changes**: If the labels on a GitHub issue are modified after creation, these changes will not alter the issue type of the already created JIRA issue. This limitation arises from the [inability of the JIRA REST API to safely change an issue type](https://jira.atlassian.com/browse/JRACLOUD-68207) when the new type is associated with a different workflow. In such scenarios, the action will leave a comment in the JIRA issue to inform about the label change in GitHub. -- [Sync a new issue to Jira](#sync-a-new-issue-to-jira) -- [Sync a new issue comment to Jira](#sync-a-new-issue-comment-to-jira) -- [Sync a new pull request to Jira](#sync-a-new-pull-request-to-jira) +## Limitations -## Sync a new issue to Jira +There are certain limitations to the data and events that can be synchronized: + +### What's Not Synced + +- **Labels**: The action does not sync labels between GitHub and JIRA, with the exception of labels that match JIRA issue types. This means that general labels used for categorization or prioritization in GitHub won't automatically reflect in JIRA. +- **Transitions**: Changes in the status of a GitHub issue, such as closing, reopening, or deleting, do not automatically result in the corresponding transition of the JIRA issue's status. Instead, these actions result in a comment being added to the linked JIRA issue to record the event. This design choice accounts for scenarios where a GitHub issue might be closed by its reporter, but the underlying problem it documents still requires attention and resolution within the JIRA project. + +## Usage Instructions for GitHub to JIRA Issue Sync Action + +This GitHub Action provides a comprehensive solution for integrating GitHub with JIRA, ensuring that issues, comments, and pull requests in GitHub are seamlessly synced to JIRA. Below are the setups to synchronize different types of activities from GitHub to JIRA. + +### Syncing New Issues to JIRA + +Automatically creates a corresponding JIRA issue when a new issue is opened in GitHub. ```yaml name: Sync issues to Jira -# This workflow will be triggered when a new issue is opened -on: issues -# Limit to single concurrent run for workflows which can create Jira issues. -# Same concurrency group is used in issue_comment.yml +on: issues concurrency: jira_issues jobs: sync_issues_to_jira: - name: Sync issues to Jira runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - name: Sync GitHub issues to Jira project - uses: espressif/github-actions/sync_issues_to_jira@master + uses: espressif/sync-jira-actions@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} JIRA_PASS: ${{ secrets.JIRA_PASS }} - JIRA_PROJECT: SOMEPROJECT - JIRA_COMPONENT: SOMECOMPONENT + JIRA_PROJECT: SOMEPROJECT # define the JIRA project here + JIRA_COMPONENT: SOMECOMPONENT # define (optional) JIRA component here JIRA_URL: ${{ secrets.JIRA_URL }} JIRA_USER: ${{ secrets.JIRA_USER }} ``` -## Sync a new issue comment to Jira +### Syncing New Issue Comments to JIRA -Syncing an issue comment works the same way. The only difference is the trigger event. +Ensures that comments made on GitHub issues are also reflected in the corresponding JIRA issue. ```yaml name: Sync issue comments to JIRA -# This workflow will be triggered when new issue comment is created (including PR comments) on: issue_comment ``` -## Sync a new pull request to Jira +### Syncing New Pull Requests to JIRA -Actions for pull requests run with the privileges of the PR submitter's repo - for security reasons, as they can modify the contents of them. -If the action is run on the PR event, it can't access the necessary GH secrets containing Jira credentials. -Therefore PR syncing has to run as a cron task which is loaded from the master branch and run with all privileges. +Due to security reasons related to the privileges of PR submitter's repositories, syncing pull requests requires a different approach, using a cron job to regularly check for and sync new pull requests. ```yaml name: Sync remaining PRs to Jira -# This workflow will be triggered every hour to sync remaining PRs (i.e. PRs with zero comment) to Jira project -# Note that PRs can also get synced when a new PR comment is created + on: schedule: - - cron: '0 * * * *' - -# Limit to single concurrent run for workflows which can create Jira issues. -# Same concurrency group is used in issue_comment.yml + - cron: "0 * * * *" concurrency: jira_issues jobs: sync_prs_to_jira: - name: Sync PRs to Jira runs-on: ubuntu-latest steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 + - name: Sync PRs to Jira project - uses: espressif/github-actions/sync_issues_to_jira@master + uses: espressif/sync-jira-actions@v1 with: cron_job: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} JIRA_PASS: ${{ secrets.JIRA_PASS }} - JIRA_PROJECT: SOMEPROJECT - JIRA_COMPONENT: SOMECOMPONENT + JIRA_PROJECT: SOMEPROJECT # define the JIRA project here + JIRA_COMPONENT: SOMECOMPONENT # define (optional) JIRA component here JIRA_URL: ${{ secrets.JIRA_URL }} JIRA_USER: ${{ secrets.JIRA_USER }} ``` -## Sync issues and pull requests manually +## Manually Syncing Issues and Pull Requests to JIRA + +For cases where you need to manually sync issues and pull requests that were not automatically captured by the [Sync a new issue to Jira](#sync-a-new-issue-to-jira) and [Sync a new pull request to Jira](#sync-a-new-pull-request-to-jira) workflows, this GitHub Action provides a solution. It allows for the manual synchronization of both new and old issues and pull requests directly to your JIRA project. + +### Configuration for Manual Sync -Actions for both issues and pull requests to sync them manually to Jira. When enabling [Sync a new issue to Jira](#sync-a-new-issue-to-jira) and [Sync a new pull request to Jira](#sync-a-new-pull-request-to-jira) actions, it will sync only newly created issues and pull requests. With this action you can manually sync all old issues and pull requests. -Action defines two input parameters: +This action introduces two parameters for manual triggering: -- `action` with default value `mirror-issues` -- `issue-numbers` with issue and pull requests numbers to be mirrored to Jira +- `action`: Specifies the action to be performed, with a default value of `mirror-issues`. +- `issue-numbers`: Lists the numbers of the issues and pull requests that you wish to sync to JIRA. + +### Workflow Setup + +To set up the manual sync action, include the following workflow in your GitHub repository: ```yaml name: Manually trigger sync issue to Jira -# This workflow will be triggered manually on: workflow_dispatch: inputs: action: - description: 'Action to be performed' + description: "Action to be performed" required: true - default: 'mirror-issues' + default: "mirror-issues" issue-numbers: - description: 'Issue numbers' + description: "Issue numbers" required: true - concurrency: jira_issues jobs: @@ -163,76 +203,50 @@ jobs: name: Sync issues to Jira runs-on: ubuntu-latest steps: - - uses: actions/checkout@master + - uses: actions/checkout@v4 + - name: Sync GitHub issues to Jira project - uses: espressif/github-actions/sync_issues_to_jira@master + uses: espressif/sync-jira-actions@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} JIRA_PASS: ${{ secrets.JIRA_PASS }} - JIRA_PROJECT: SOMEPROJECT - JIRA_COMPONENT: SOMECOMPONENT + JIRA_PROJECT: SOMEPROJECT # define the JIRA project here + JIRA_COMPONENT: SOMECOMPONENT # define (optional) JIRA component here JIRA_URL: ${{ secrets.JIRA_URL }} JIRA_USER: ${{ secrets.JIRA_USER }} ``` -# Variables +This workflow can be triggered manually from the GitHub Actions tab in your repository, allowing you to specify the issues or pull requests to be synced by entering their numbers. -The environment variables should be set in the GitHub Workflow: +This ensures that even items not caught by the automatic sync process can still be integrated into your JIRA project for tracking and management. -- `JIRA_PROJECT` is the slug of the JIRA project to create new issues in. -- `JIRA_ISSUE_TYPE` (optional) the JIRA issue type for new issues. If unset, "Task" is used. -- `JIRA_COMPONENT` (optional) the name of a JIRA component to add to every issue which is synced from GitHub. The component must already exist in the JIRA project. +## Environment Variables and Secrets Configuration -The following secrets are needed for the workflow: +The GitHub to JIRA Issue Sync workflow requires the configuration of specific environment variables and secrets to operate effectively. These settings ensure the correct creation and updating of issues within your JIRA project based on activities in your GitHub repository. -- `JIRA_URL` is the main JIRA URL (doesn't have to be secret). -- `JIRA_USER` is the JIRA username to log in with (JIRA basic auth) -- `JIRA_PASS` is JIRA token (JIRA token auth) or JIRA password (JIRA basic auth) to log in with +Below is a detailed table outlining the necessary configurations: -If `JIRA_PASS` is a token, it must be entered in the secret with the prefix `token:` (e.g.: `token:Xyz123**************ABC`). The `token:` prefix is used to distinguish between a password and a token type of `JIRA_PASS`. This prefix will be stripped by the script before the API call. +| Variable/Secret | Description | Requirement | +| ----------------- | -------------------------------------------------------------------------------------------- | ----------- | +| `JIRA_PROJECT` | The slug of the JIRA project where new issues will be created. | Mandatory | +| `JIRA_URL` | The main URL of your JIRA instance. | Inherited | +| `JIRA_USER` | The username used for logging into JIRA (basic auth). | Inherited | +| `JIRA_PASS` | The JIRA token (for token auth) or password (for basic auth) used for logging in. | Inherited | +| `JIRA_ISSUE_TYPE` | Specifies the JIRA issue type for new issues. Defaults to "Task" if not set. | Optional | +| `JIRA_COMPONENT` | The name of a JIRA component to add to every synced issue. The component must exist in JIRA. | Optional | -***IMPORTANT:** These secrets are inherited from the GitHub organizational secrets (as they are common to all Espressif GitHub projects) and should not be set at the repository level. (If set at the repository level, repo secrets take precedence over org secrets.)* +### Important Consideration: -# Tests +- **GitHub Organizational Secrets**: `JIRA_URL`, `JIRA_USER`, `JIRA_PASS` - These secrets are **inherited from the GitHub organizational secrets, as they are common to all projects within the organization**. It is advised not to set these secrets at the individual repository level to avoid conflicts and ensure a unified configuration across all projects. -test_sync_issue.py is a Python unittest framework that uses unittest.mock to create a mock JIRA API, then calls unit_test.py with various combinations of payloads similar to real GitHub Actions payloads. +- **Token as JIRA_PASS**: When using a token for `JIRA_PASS`, prefix the token value with `token:` (e.g., `token:Xyz123**************ABC`). This prefix helps distinguish between password and token types, and it will be removed by the script before making the API call. -The best way to run the tests is in the docker container, as this is the same environment that GitHub will run real actions in. +--- -## Build image and run tests in a temporary container: +## Project Issues -``` -docker build . --tag jira-sync && docker run --rm --entrypoint=/test_sync_to_jira.py jira-sync -``` - -## Rebuild container and run tests multiple times - -(This is a bit faster than rebuilding the image each time.) - -Build the image and run the container once: +If you encounter any issues, feel free to report them in the project's issues or create Pull Request with your suggestion. -``` -docker build . --tag jira-sync -docker run -td --name jira-sync --entrypoint=/bin/sh jira-sync -``` - -For each test run, copy the Python files to the running container and run the test program: +## Contributing -``` -docker cp . jira-sync:/ && docker exec jira-sync /test_sync_to_jira.py -``` - -Once finished, kill the container: - -``` -docker stop -t1 jira-sync -``` - -## Cleanup - -To clean up the container and container image: - -``` -docker rm jira-sync -docker rmi jira-sync -``` +📘 If you are interested in contributing to this project, see the [project Contributing Guide](CONTRIBUTING.md). diff --git a/docs/sync-jira-actions.png b/docs/sync-jira-actions.png new file mode 100644 index 0000000..a7a8f93 Binary files /dev/null and b/docs/sync-jira-actions.png differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c7cfe2b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,149 @@ +[build-system] + requires = ["setuptools-scm>=8.0", "setuptools>=60"] + +[project] + authors = [ + { name = "Tomas Sebestik (Espressif Systems)", email = "tomas.sebestik@espressif.com" }, + ] + classifiers = ["Programming Language :: Python :: 3"] + description = "Espressif GitHub Actions for JIRA synchronization" + dynamic = ["version"] + name = "sync-jira-actions" + readme = "README.md" + requires-python = ">=3.11" + + dependencies = ["PyGithub==2.2.0", "jira==3.6.0"] + + [project.optional-dependencies] + dev = [ + "commitizen", + "pip-tools~=7.3", + "pre-commit>=3.3", + "pytest", + "pytest-cov", + ] + +[tool.setuptools_scm] + write_to = "sync_jira_actions/version.py" + +[tool.black] + line-length = 120 # The maximum line length for Python code formatting + skip-string-normalization = true # Avoids converting single quotes to double quotes in strings (pre-commit hook enforces single quotes in Python code) + +[tool.ruff] + line-length = 120 # Specifies the maximum line length for ruff checks + select = ['E', 'F', 'W'] # Types of issues ruff should check for + target-version = "py311" # Specifies the target Python version for ruff checks + +[tool.mypy] + disallow_incomplete_defs = false # Disallows defining functions with incomplete type annotations + disallow_untyped_defs = false # Disallows defining functions without type annotations or with incomplete type annotations + exclude = '^venv/' # Paths to ignore during type checking + ignore_missing_imports = true # Suppress error messages about imports that cannot be resolved + python_version = "3.11" # Specifies the Python version used to parse and check the target program + warn_no_return = true # Shows errors for missing return statements on some execution paths + warn_return_any = true # Shows a warning when returning a value with type Any from a function declared with a non- Any return type + +[tool.pylint] + [tool.pylint.MASTER] + ignore-paths = ["tests/.*"] # Paths to ignore during linting + [tool.pylint.'BASIC'] + variable-rgx = "[a-z_][a-z0-9_]{1,30}$" # Variable names must start with a lowercase letter or underscore, followed by any combination of lowercase letters, numbers, or underscores, with a total length of 2 to 30 characters. + [tool.pylint.'MESSAGES CONTROL'] + disable = [ + "duplicate-code", # R0801: Similar lines in %s files + "fixme", # W0511: Used when TODO/FIXME is encountered + "import-error", # E0401: Used when pylint has been unable to import a module + "import-outside-toplevel", # E0402: Imports should usually be on top of the module + "logging-fstring-interpolation", # W1202: Use % formatting in logging functions and pass the % parameters as arguments + "missing-class-docstring", # C0115: Missing class docstring + "missing-function-docstring", # C0116: Missing function or method docstring + "missing-module-docstring", # C0114: Missing module docstring + "no-name-in-module", # W0611: Used when a name cannot be found in a module + "too-few-public-methods", # R0903: Too few public methods of class + "too-many-branches", # R0912: Too many branches + "too-many-locals", # R0914: Too many local variables + "too-many-return-statements", # R0911: Too many return statements + "too-many-statements", # R0915: Too many statements + "ungrouped-imports", # C0412: Imports should be grouped by packages + ] + [tool.pylint.'FORMAT'] + max-line-length = 120 # Specifies the maximum line length for pylint checks + + + [tool.pytest.ini_options] + addopts = "-s --log-cli-level DEBUG --cov=. --cov-report=term" + python_classes = ["Test*"] + python_files = ["test_*.py"] + python_functions = ["test_*"] + testpaths = ["tests"] + + [tool.coverage.run] + omit = ["__*__.py", "tests/*"] + +[tool.commitizen] + annotated_tag = true + bump_message = "change: release v$new_version" + name = "cz_customize" + tag_format = "v$version" + update_changelog_on_bump = true + version_provider = "scm" + + [tool.commitizen.customize] + bump_map = { "change" = "MINOR", "feat" = "MINOR", "fix" = "PATCH", "refactor" = "PATCH", "remove" = "PATCH", "revert" = "PATCH" } + bump_pattern = "^(change|feat|fix|refactor|remove|revert)" + change_type_order = [ + "change", + "ci", + "docs", + "feat", + "fix", + "refactor", + "remove", + "revert", + ] + example = "change: this is a custom change type" + message_template = "{% if scope %}{{change_type}}({{scope}}): {{message}}{% else %}{{change_type}}: {{message}}{% endif %}{% if body %}\n\n{{body}}{% endif %}{% if is_breaking_change %}\n\nBREAKING CHANGE{% endif %}{% if footer %}\n\n{{footer}}{% endif %}" + schema = "(): " + schema_pattern = "^([a-z]+)(\\([\\w\\-\\.]+\\))?:\\s.*" + + [[tool.commitizen.customize.questions]] + choices = [ + { value = "change", name = "change: A change made to the codebase." }, + { value = "ci", name = "ci: Changes to our CI configuration files and scripts." }, + { value = "docs", name = "docs: Documentation only changes." }, + { value = "feat", name = "feat: A new feature." }, + { value = "fix", name = "fix: A bug fix." }, + { value = "refactor", name = "refactor: A code change that neither fixes a bug nor adds a feature." }, + { value = "remove", name = "remove: Removing code or files." }, + { value = "revert", name = "revert: Revert to a commit." }, + ] + message = "Select the TYPE of change you are committing" + name = "change_type" + type = "list" + + [[tool.commitizen.customize.questions]] + message = "What is the SCOPE of this change (press enter to skip)?" + name = "scope" + type = "input" + + [[tool.commitizen.customize.questions]] + message = "Describe the changes made (SUMMARY of commit message):" + name = "message" + type = "input" + + [[tool.commitizen.customize.questions]] + message = "Provide additional contextual information - commit message BODY: (press [enter] to skip)" + name = "body" + type = "input" + + [[tool.commitizen.customize.questions]] + default = false + message = "Is this a BREAKING CHANGE? Correlates with MAJOR in SemVer" + name = "is_breaking_change" + type = "confirm" + + [[tool.commitizen.customize.questions]] + message = "Footer. Information about Breaking Changes and reference issues that this commit closes: (press [enter] to skip)" + name = "footer" + type = "input" diff --git a/requirements.txt b/requirements.txt index a02bf1b..37e0bc2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,58 @@ -jira>=3.0.1<3.1.0 -PyGithub>=1.55<1.56 +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --output-file=requirements.txt pyproject.toml +# +certifi==2024.2.2 + # via requests +cffi==1.16.0 + # via + # cryptography + # pynacl +charset-normalizer==3.3.2 + # via requests +cryptography==42.0.2 + # via pyjwt +defusedxml==0.7.1 + # via jira +deprecated==1.2.14 + # via pygithub +idna==3.6 + # via requests +jira==3.6.0 + # via sync-jira-actions (pyproject.toml) +oauthlib==3.2.2 + # via requests-oauthlib +packaging==23.2 + # via jira +pillow==10.2.0 + # via jira +pycparser==2.21 + # via cffi +pygithub==2.2.0 + # via sync-jira-actions (pyproject.toml) +pyjwt[crypto]==2.8.0 + # via pygithub +pynacl==1.5.0 + # via pygithub +requests==2.31.0 + # via + # jira + # pygithub + # requests-oauthlib + # requests-toolbelt +requests-oauthlib==1.3.1 + # via jira +requests-toolbelt==1.0.0 + # via jira +typing-extensions==4.9.0 + # via + # jira + # pygithub +urllib3==2.2.0 + # via + # pygithub + # requests +wrapt==1.16.0 + # via deprecated diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 46e8615..0000000 --- a/setup.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -ignore = -max-line-length = 160 - diff --git a/sync_jira_actions/__init__.py b/sync_jira_actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sync_issue.py b/sync_jira_actions/sync_issue.py similarity index 68% rename from sync_issue.py rename to sync_jira_actions/sync_issue.py index 441fb9d..83d8b1d 100755 --- a/sync_issue.py +++ b/sync_jira_actions/sync_issue.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2019 Espressif Systems (Shanghai) PTE LTD +# Copyright 2019-2024 Espressif Systems (Shanghai) CO LTD # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,30 +14,29 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from jira import JIRA, JIRAError -from github import Github -from github.GithubException import GithubException -import json import os import random import re import subprocess -import sys import tempfile import time +from github import Github +from github.GithubException import GithubException +from jira import JIRAError + # 10101 is ID for New Feature issue type in Jira. JIRA_NEW_FEATURE_TYPE_ID = 10101 # 10004 is ID for Bug issue type in Jira. JIRA_BUG_TYPE_ID = 10004 # Initialize GitHub instance -GITHUB = Github(os.environ["GITHUB_TOKEN"]) +GITHUB = Github(os.environ['GITHUB_TOKEN']) # Initialize GitHub repository REPO = GITHUB.get_repo(os.environ['GITHUB_REPOSITORY']) def handle_issue_opened(jira, event): - gh_issue = event["issue"] + gh_issue = event['issue'] issue = _find_jira_issue(jira, gh_issue, False) if issue is not None: @@ -45,16 +44,16 @@ def handle_issue_opened(jira, event): return print('Creating new JIRA issue for new GitHub issue') - _create_jira_issue(jira, event["issue"]) + _create_jira_issue(jira, event['issue']) def handle_issue_edited(jira, event): - gh_issue = event["issue"] + gh_issue = event['issue'] issue = _find_jira_issue(jira, gh_issue, True) fields = { - "description": _get_description(gh_issue), - "summary": _get_summary(gh_issue), + 'description': _get_description(gh_issue), + 'summary': _get_summary(gh_issue), } _update_components_field(jira, fields, issue) @@ -63,85 +62,85 @@ def handle_issue_edited(jira, event): _update_link_resolved(jira, gh_issue, issue) - _leave_jira_issue_comment(jira, event, "edited", True, jira_issue=issue) + _leave_jira_issue_comment(jira, event, 'edited', True, jira_issue=issue) def handle_issue_closed(jira, event): # note: Not auto-closing the synced JIRA issue because GitHub # issues often get closed for the wrong reasons - ie the user # found a workaround but the root cause still exists. - issue = _leave_jira_issue_comment(jira, event, "closed", False) - try : + issue = _leave_jira_issue_comment(jira, event, 'closed', False) + try: # Sets value of custom GitHub Issue field to Closed issue.update(fields={'customfield_12100': {'value': 'Closed'}}) except JIRAError as error: print(f'Could not set GitHub Issue field to Closed when closing issue with error: {error}') if issue is not None: - _update_link_resolved(jira, event["issue"], issue) + _update_link_resolved(jira, event['issue'], issue) def handle_issue_labeled(jira, event): - gh_issue = event["issue"] - jira_issue = _find_jira_issue(jira, gh_issue, gh_issue["state"] == "open") + gh_issue = event['issue'] + jira_issue = _find_jira_issue(jira, gh_issue, gh_issue['state'] == 'open') if jira_issue is None: return labels = list(jira_issue.fields.labels) - new_label = _get_jira_label(event["label"]) + new_label = _get_jira_label(event['label']) if _check_issue_label(new_label) is None: return if new_label not in labels: labels.append(new_label) - jira_issue.update(fields={"labels": labels}) + jira_issue.update(fields={'labels': labels}) def handle_issue_unlabeled(jira, event): - gh_issue = event["issue"] - jira_issue = _find_jira_issue(jira, gh_issue, gh_issue["state"] == "open") + gh_issue = event['issue'] + jira_issue = _find_jira_issue(jira, gh_issue, gh_issue['state'] == 'open') if jira_issue is None: return labels = list(jira_issue.fields.labels) - removed_label = _get_jira_label(event["label"]) + removed_label = _get_jira_label(event['label']) if _check_issue_label(removed_label) is None: return try: labels.remove(removed_label) - jira_issue.update(fields={"labels": labels}) + jira_issue.update(fields={'labels': labels}) except ValueError: pass # not in labels list def handle_issue_deleted(jira, event): - _leave_jira_issue_comment(jira, event, "deleted", False) + _leave_jira_issue_comment(jira, event, 'deleted', False) def handle_issue_reopened(jira, event): - issue = _leave_jira_issue_comment(jira, event, "reopened", True) - try : - # Sets value of custom GitHub Issue field to Open + issue = _leave_jira_issue_comment(jira, event, 'reopened', True) + try: + # Sets value of custom GitHub Issue field to Open issue.update(fields={'customfield_12100': {'value': 'Open'}}) except JIRAError as error: print(f'Could not set GitHub Issue field to Open when reopening issue with error: {error}') - _update_link_resolved(jira, event["issue"], issue) + _update_link_resolved(jira, event['issue'], issue) def handle_comment_created(jira, event): - gh_comment = event["comment"] + gh_comment = event['comment'] - jira_issue = _find_jira_issue(jira, event["issue"], True) + jira_issue = _find_jira_issue(jira, event['issue'], True) jira.add_comment(jira_issue.id, _get_jira_comment_body(gh_comment)) def handle_comment_edited(jira, event): - gh_comment = event["comment"] - old_gh_body = _markdown2wiki(event["changes"]["body"]["from"]) + gh_comment = event['comment'] + old_gh_body = _markdown2wiki(event['changes']['body']['from']) - jira_issue = _find_jira_issue(jira, event["issue"], True) + jira_issue = _find_jira_issue(jira, event['issue'], True) # Look for the old comment and update it if we find it old_jira_body = _get_jira_comment_body(gh_comment, old_gh_body) @@ -157,10 +156,10 @@ def handle_comment_edited(jira, event): def handle_comment_deleted(jira, event): - gh_comment = event["comment"] - jira_issue = _find_jira_issue(jira, event["issue"], True) + gh_comment = event['comment'] + jira_issue = _find_jira_issue(jira, event['issue'], True) jira.add_comment( - jira_issue.id, "@%s deleted [GitHub issue comment|%s]" % (gh_comment["user"]["login"], gh_comment["html_url"]) + jira_issue.id, f"@{gh_comment['user']['login']} deleted [GitHub issue comment|{gh_comment['html_url']}]" ) @@ -168,7 +167,7 @@ def handle_comment_deleted(jira, event): def sync_issues_manually(jira, event): # Get issue numbers that were entered manually when triggering workflow issue_numbers = event['inputs']['issue-numbers'] - issues = re.split('\W+', issue_numbers) + issues = re.split(r'\W+', issue_numbers) # Process every issue for issue_number in issues: if not issue_number.isnumeric(): @@ -185,7 +184,7 @@ def _check_issue_label(label): Ignore labels that start with "Status:" and "Resolution:". These labels are mirrored from Jira issue and should not be mirrored back as labels """ - ignore_prefix = ("status:", "resolution:") + ignore_prefix = ('status:', 'resolution:') if label.lower().startswith(ignore_prefix): return None @@ -201,12 +200,12 @@ def _update_link_resolved(jira, gh_issue, jira_issue): Also updates the link title, if GitHub issue title has changed. """ - resolved = gh_issue["state"] != "open" + resolved = gh_issue['state'] != 'open' for link in jira.remote_links(jira_issue): - if hasattr(link, "globalId") and link.globalId == gh_issue["html_url"]: - new_link = dict(link.raw["object"]) # RemoteLink update() requires all fields as a JSON object, it seems - new_link["title"] = gh_issue["title"] - new_link["status"]["resolved"] = resolved + if hasattr(link, 'globalId') and link.globalId == gh_issue['html_url']: + new_link = dict(link.raw['object']) # RemoteLink update() requires all fields as a JSON object, it seems + new_link['title'] = gh_issue['title'] + new_link['status']['resolved'] = resolved link.update(new_link, globalId=link.globalId, relationship=link.relationship) @@ -215,24 +214,24 @@ def _markdown2wiki(markdown): Convert markdown to JIRA wiki format. Uses https://github.com/Shogobg/markdown2confluence """ if markdown is None: - return "\n" # Allow empty/blank input + return '\n' # Allow empty/blank input with tempfile.TemporaryDirectory() as tmp_dir: md_path = os.path.join(tmp_dir, 'markdown.md') conf_path = os.path.join(tmp_dir, 'confluence.txt') - with open(md_path, 'w') as mdf: + with open(md_path, 'w', encoding='utf-8') as mdf: mdf.write(markdown) if not markdown.endswith('\n'): mdf.write('\n') try: subprocess.check_call(['markdown2confluence', md_path, conf_path]) - with open(conf_path, 'r') as f: - result = f.read() + with open(conf_path, 'r', encoding='utf-8') as file: + result = file.read() if len(result) > 16384: # limit any single body of text to 16KB (JIRA API limits total text to 32KB) - result = result[:16376] + "\n\n[...]" # add newlines to encourage end of any formatting blocks + result = result[:16376] + '\n\n[...]' # add newlines to encourage end of any formatting blocks return result - except subprocess.CalledProcessError as e: - print("Failed to run markdown2confluence: %s. JIRA issue will have raw Markdown contents." % e) + except subprocess.CalledProcessError as error: + print(f'Failed to run markdown2confluence: {error}. JIRA issue will have raw Markdown contents.') return markdown @@ -240,7 +239,7 @@ def _get_description(gh_issue): """ Return the JIRA description text that corresponds to the provided GitHub issue. """ - is_pr = "pull_request" in gh_issue + is_pr = 'pull_request' in gh_issue description_format = """ [GitHub %(type)s|%(github_url)s] from user @%(github_user)s: @@ -254,7 +253,7 @@ def _get_description(gh_issue): * Do not edit this description text, it may be updated automatically. * Please interact on GitHub where possible, changes will sync to here. """ - description_format = description_format.lstrip("\n") + description_format = description_format.lstrip('\n') if not is_pr: # additional dot point only shown for issues not PRs @@ -267,10 +266,10 @@ def _get_description(gh_issue): """ return description_format % { - "type": "Pull Request" if is_pr else "Issue", - "github_url": gh_issue["html_url"], - "github_user": gh_issue["user"]["login"], - "github_description": _markdown2wiki(gh_issue["body"]), + 'type': 'Pull Request' if is_pr else 'Issue', + 'github_url': gh_issue['html_url'], + 'github_user': gh_issue['user']['login'], + 'github_description': _markdown2wiki(gh_issue['body']), } @@ -280,12 +279,12 @@ def _get_summary(gh_issue): Format is: GH/PR #: """ - is_pr = "pull_request" in gh_issue - result = "%s #%d: %s" % ("PR" if is_pr else "GH", gh_issue["number"], gh_issue["title"]) + is_pr = 'pull_request' in gh_issue + result = f"{'PR' if is_pr else 'GH'} #{gh_issue['number']}: {gh_issue['title']}" # don't mirror any existing JIRA slug-like pattern from GH title to JIRA summary # (note we don't look for a particular pattern as the JIRA issue may have moved) - result = re.sub(r" \([\w]+-[\d]+\)", "", result) + result = re.sub(r' \([\w]+-[\d]+\)', '', result) return result @@ -299,25 +298,25 @@ def _create_jira_issue(jira, gh_issue): issuetype = os.environ.get('JIRA_ISSUE_TYPE', 'Task') fields = { - "summary": _get_summary(gh_issue), - "project": os.environ['JIRA_PROJECT'], - "description": _get_description(gh_issue), - "issuetype": issuetype, - "labels": [_get_jira_label(l) for l in gh_issue["labels"]], + 'summary': _get_summary(gh_issue), + 'project': os.environ['JIRA_PROJECT'], + 'description': _get_description(gh_issue), + 'issuetype': issuetype, + 'labels': [_get_jira_label(lbl) for lbl in gh_issue['labels']], } _update_components_field(jira, fields, None) issue = jira.create_issue(fields) - - try : - # Sets value of custom GitHub Issue field to Open + + try: + # Sets value of custom GitHub Issue field to Open issue.update(fields={'customfield_12100': {'value': 'Open'}}) except JIRAError as error: print(f'Could not set GitHub Issue field to Open when creating new issue with error: {error}') _add_remote_link(jira, issue, gh_issue) _update_github_with_jira_key(gh_issue, issue) - if gh_issue["state"] != "open": + if gh_issue['state'] != 'open': # mark the link to GitHub as resolved _update_link_resolved(jira, gh_issue, issue) @@ -328,15 +327,15 @@ def _add_remote_link(jira, issue, gh_issue): """ Add the JIRA "remote link" field that points to the issue """ - gh_url = gh_issue["html_url"] + gh_url = gh_issue['html_url'] jira.add_remote_link( issue=issue, destination={ - "url": gh_url, - "title": gh_issue["title"], + 'url': gh_url, + 'title': gh_issue['title'], }, globalId=gh_url, # globalId is always the GitHub URL - relationship="synced from", + relationship='synced from', ) @@ -345,17 +344,17 @@ def _update_github_with_jira_key(gh_issue, jira_issue): (updates made by github actions don't trigger new actions) """ - api_gh_issue = REPO.get_issue(gh_issue["number"]) + api_gh_issue = REPO.get_issue(gh_issue['number']) retries = 5 while True: try: - api_gh_issue.edit(title="%s (%s)" % (api_gh_issue.title, jira_issue.key)) + api_gh_issue.edit(title=f'{api_gh_issue.title} ({jira_issue.key})') break - except GithubException as e: + except GithubException as error: if retries == 0: raise - print("GitHub edit failed: %s (%d retries)" % (e, retries)) + print(f'GitHub edit failed: {error} ({retries} retries)') time.sleep(random.randrange(1, 5)) retries -= 1 @@ -373,8 +372,8 @@ def _update_components_field(jira, fields, existing_issue=None): fatal errors (especially likely if the JIRA issue has been moved between projects) . """ component = os.environ.get('JIRA_COMPONENT', '') - if not len(component): - print("No JIRA_COMPONENT variable set, not updating components field") + if not component: + print('No JIRA_COMPONENT variable set, not updating components field') return if existing_issue: @@ -388,14 +387,14 @@ def _update_components_field(jira, fields, existing_issue=None): print("JIRA project doesn't contain the configured component, not updating components field") return - print("Setting components field") + print('Setting components field') - fields["components"] = [{"name": component}] + fields['components'] = [{'name': component}] # keep any existing components as well if existing_issue: for component in existing_issue.fields.components: if component.name != component: - fields["components"].append({"name": component.name}) + fields['components'].append({'name': component.name}) def _get_jira_issue_type(jira, gh_issue): @@ -406,7 +405,7 @@ def _get_jira_issue_type(jira, gh_issue): NOTE: This is only suitable for setting on new issues. Changing issue type is unsafe. See https://jira.atlassian.com/browse/JRACLOUD-68207 """ - gh_labels = [l["name"] for l in gh_issue["labels"]] + gh_labels = [lbl['name'] for lbl in gh_issue['labels']] issue_types = jira.issue_types() @@ -414,18 +413,18 @@ def _get_jira_issue_type(jira, gh_issue): # Type: Feature Request label should match New Feature issue type in Jira if gh_label == 'Type: Feature Request': print('GitHub label is \'Type: Feature Request\'. Mapping to New Feature Jira issue type') - return {"id": JIRA_NEW_FEATURE_TYPE_ID} # JIRA API needs JSON here + return {'id': JIRA_NEW_FEATURE_TYPE_ID} # JIRA API needs JSON here # Some projects use Label with bug icon represented by ":bug:" in label name. - # This if mathes those to Bug Jira issue type + # This if matches those to Bug Jira issue type if gh_label == 'Type: Bug :bug:': print('GitHub label is \'Type: Bug :bug:\'. Mapping to Bug Jira issue type') - return {"id": JIRA_BUG_TYPE_ID} # JIRA API needs JSON here + return {'id': JIRA_BUG_TYPE_ID} # JIRA API needs JSON here for issue_type in issue_types: type_name = issue_type.name.lower() - if gh_label.lower() in [type_name, "type: %s" % (type_name,)]: + if gh_label.lower() in [type_name, f'type: {type_name}']: # a match! - print("Mapping GitHub label '%s' to JIRA issue type '%s'" % (gh_label, issue_type.name)) - return {"id": issue_type.id} # JIRA API needs JSON here + print(f"Mapping GitHub label '{gh_label}' to JIRA issue type '{issue_type.name}'") + return {'id': issue_type.id} # JIRA API needs JSON here return None # updating a field to None seems to cause 'no change' for JIRA @@ -444,23 +443,24 @@ def _find_jira_issue(jira, gh_issue, make_new=False, retries=5): flurry (for example if someone creates and then edits or labels an issue), # and they're not always processed in order. """ - url = gh_issue["html_url"] - jql_query = 'issue in issuesWithRemoteLinksByGlobalId("%s") order by updated desc' % url - print("JQL query: %s" % jql_query) - r = jira.search_issues(jql_query) - if len(r) == 0: - print("WARNING: No JIRA issues have a remote link with globalID '%s'" % url) - - # Check if the github title ends in (JIRA-KEY). If we can find that JIRA issue and the JIRA issue description contains the + url = gh_issue['html_url'] + jql_query = f'issue in issuesWithRemoteLinksByGlobalId("{url}") order by updated desc' + print(f'JQL query: {jql_query}') + res = jira.search_issues(jql_query) + if not res: + print(f"WARNING: No JIRA issues have a remote link with globalID '{url}'") + + # Check if the github title ends in (JIRA-KEY). If we can find that JIRA issue and + # the JIRA issue description contains the # GitHub URL, assume this item was manually synced over. - m = re.search(r"\(([A-Z]+-\d+)\)\s*$", gh_issue["title"]) - if m is not None: + match = re.search(r'\(([A-Z]+-\d+)\)\s*$', gh_issue['title']) + if match is not None: try: - issue = jira.issue(m.group(1)) - if gh_issue["html_url"] in issue.fields.description: + issue = jira.issue(match.group(1)) + if gh_issue['html_url'] in issue.fields.description: print( - "Looks like this JIRA issue %s was manually synced. Adding a remote link for future lookups." - % issue.key + f'Looks like this JIRA issue {issue.key} was manually synced.' + 'Adding a remote link for future lookups.' ) _add_remote_link(jira, issue, gh_issue) return issue @@ -472,7 +472,8 @@ def _find_jira_issue(jira, gh_issue, make_new=False, retries=5): if not make_new: return None - elif retries > 0: + + if retries > 0: # Wait a random amount of time to see if this JIRA issue is still being created by another # GitHub Action. This is a hacky way to try and avoid the case where a GitHub issue is created # and edited in a short window of time, and the two GitHub Actions race each other and produce @@ -482,41 +483,44 @@ def _find_jira_issue(jira, gh_issue, make_new=False, retries=5): # delayed by more than (retries * min(range)) seconds. This does mean that it can take up to 5 # minutes for an old issue (created before the sync was installed), or an issue where the created # event sync failed, to sync in. - print('Waiting to see if issue is created by another Action... (retries={})'.format(retries)) + print(f'Waiting to see if issue is created by another Action... (retries={retries})') time.sleep(random.randrange(30, 60)) return _find_jira_issue(jira, gh_issue, True, retries - 1) - else: - print('Creating missing issue in JIRA') - return _create_jira_issue(jira, gh_issue) - if len(r) > 1: - print("WARNING: Remote Link globalID '%s' returns multiple JIRA issues. Using last-updated only." % url) - return r[0] + + print('Creating missing issue in JIRA') + return _create_jira_issue(jira, gh_issue) + + if len(res) > 1: + print(f"WARNING: Remote Link globalID '{url}' returns multiple JIRA issues. Using last-updated only.") + return res[0] def _leave_jira_issue_comment(jira, event, verb, should_create, jira_issue=None): """ Leave a simple comment that the GitHub issue corresponding to this event was 'verb' by the GitHub user in question. - If jira_issue is set then this JIRA issue will be updated, otherwise the function will find the corresponding synced issue. + If jira_issue is set then this JIRA issue will be updated, + otherwise the function will find the corresponding synced issue. If should_create is set then a new JIRA issue will be opened if one can't be found. """ - gh_issue = event["issue"] - is_pr = "pull_request" in gh_issue + gh_issue = event['issue'] + is_pr = 'pull_request' in gh_issue if jira_issue is None: - jira_issue = _find_jira_issue(jira, event["issue"], should_create) + jira_issue = _find_jira_issue(jira, event['issue'], should_create) if jira_issue is None: return None try: - user = event["sender"]["login"] + user = event['sender']['login'] except KeyError: - user = gh_issue["user"]["login"] + user = gh_issue['user']['login'] jira.add_comment( jira_issue.id, - "The [GitHub %s|%s] has been %s by @%s" % ("PR" if is_pr else "issue", gh_issue["html_url"], verb, user), + f"The [GitHub {'PR' if is_pr else 'issue'}|{gh_issue['html_url']}] has been {verb} by @{user}", ) + return jira_issue @@ -526,10 +530,10 @@ def _get_jira_comment_body(gh_comment, body=None): or on an existing comment body message (if set). """ if body is None: - body = _markdown2wiki(gh_comment["body"]) - return "[GitHub issue comment|%s] by @%s:\n\n%s" % (gh_comment["html_url"], gh_comment["user"]["login"], body) + body = _markdown2wiki(gh_comment['body']) + return f"[GitHub issue comment|{gh_comment['html_url']}] by @{gh_comment['user']['login']}:\n\n{body}" def _get_jira_label(gh_label): """Reformat a github API label item as something suitable for JIRA""" - return gh_label["name"].replace(" ", "-") + return gh_label['name'].replace(' ', '-') diff --git a/sync_pr.py b/sync_jira_actions/sync_pr.py similarity index 64% rename from sync_pr.py rename to sync_jira_actions/sync_pr.py index c137e49..9474067 100755 --- a/sync_pr.py +++ b/sync_jira_actions/sync_pr.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2019 Espressif Systems (Shanghai) PTE LTD +# Copyright 2019-2024 Espressif Systems (Shanghai) CO LTD # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,9 +15,10 @@ # limitations under the License. # import os -from jira import JIRA + from github import Github -from sync_issue import _find_jira_issue, _create_jira_issue +from sync_issue import _create_jira_issue +from sync_issue import _find_jira_issue def sync_remain_prs(jira): @@ -26,18 +27,20 @@ def sync_remain_prs(jira): """ github = Github(os.environ['GITHUB_TOKEN']) repo = github.get_repo(os.environ['GITHUB_REPOSITORY']) - prs = repo.get_pulls(state="open", sort="created", direction="desc") + prs = repo.get_pulls(state='open', sort='created', direction='desc') for pr in prs: if not repo.has_in_collaborators(pr.user.login): # mock a github issue using current PR - gh_issue = {"pull_request": True, - "labels": [{"name": l.name} for l in pr.labels], - "number": pr.number, - "title": pr.title, - "html_url": pr.html_url, - "user": {"login": pr.user.login}, - "state": pr.state, - "body": pr.body} + gh_issue = { + 'pull_request': True, + 'labels': [{'name': lbl.name} for lbl in pr.labels], + 'number': pr.number, + 'title': pr.title, + 'html_url': pr.html_url, + 'user': {'login': pr.user.login}, + 'state': pr.state, + 'body': pr.body, + } issue = _find_jira_issue(jira, gh_issue) if issue is None: _create_jira_issue(jira, gh_issue) diff --git a/sync_to_jira.py b/sync_jira_actions/sync_to_jira.py similarity index 71% rename from sync_to_jira.py rename to sync_jira_actions/sync_to_jira.py index 924db37..f01ffd2 100755 --- a/sync_to_jira.py +++ b/sync_jira_actions/sync_to_jira.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2019 Espressif Systems (Shanghai) PTE LTD +# Copyright 2019 Espressif Systems (Shanghai) CO LTD # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,13 +14,23 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from jira import JIRA -from github import Github -import os -import sys import json +import os + +from github import Github +from jira import JIRA +from sync_issue import handle_comment_created +from sync_issue import handle_comment_deleted +from sync_issue import handle_comment_edited +from sync_issue import handle_issue_closed +from sync_issue import handle_issue_deleted +from sync_issue import handle_issue_edited +from sync_issue import handle_issue_labeled +from sync_issue import handle_issue_opened +from sync_issue import handle_issue_reopened +from sync_issue import handle_issue_unlabeled +from sync_issue import sync_issues_manually from sync_pr import sync_remain_prs -from sync_issue import * class _JIRA(JIRA): @@ -43,26 +53,28 @@ def main(): # Check if the JIRA_PASS is token or password token_or_pass = os.environ['JIRA_PASS'] if token_or_pass.startswith('token:'): - print("Authenticating with JIRA_TOKEN ...") + print('Authenticating with JIRA_TOKEN ...') token = token_or_pass[6:] # Strip the 'token:' prefix jira = _JIRA(os.environ['JIRA_URL'], token_auth=token) else: - print("Authenticating with JIRA_USER and JIRA_PASS ...") + print('Authenticating with JIRA_USER and JIRA_PASS ...') jira = _JIRA(os.environ['JIRA_URL'], basic_auth=(os.environ['JIRA_USER'], token_or_pass)) # Check if it's a cron job if os.environ.get('INPUT_CRON_JOB'): + print('Running as a cron job. Syncing remaining PRs...') sync_remain_prs(jira) return # The path of the file with the complete webhook event payload. For example, /github/workflow/event.json. - with open(os.environ['GITHUB_EVENT_PATH'], 'r') as f: - event = json.load(f) + with open(os.environ['GITHUB_EVENT_PATH'], 'r', encoding='utf-8') as file: + event = json.load(file) print(json.dumps(event, indent=4)) event_name = os.environ['GITHUB_EVENT_NAME'] - # Check if event is workflow_dispatch and action is mirror issues. If so, run manual mirroring and skip rest of the script. Works both for issues and pull requests. + # Check if event is workflow_dispatch and action is mirror issues. + # If so, run manual mirroring and skip rest of the script. Works both for issues and pull requests. if event_name == 'workflow_dispatch': inputs = event.get('inputs') @@ -84,23 +96,23 @@ def main(): return # The name of the webhook event that triggered the workflow. - action = event["action"] + action = event['action'] if event_name == 'pull_request': # Treat pull request events just like issues events for syncing purposes # (we can check the 'pull_request' key in the "issue" later to know if this is an issue or a PR) event_name = 'issues' - event["issue"] = event["pull_request"] - if "pull_request" not in event["issue"]: - event["issue"]["pull_request"] = True # we don't care about the value + event['issue'] = event['pull_request'] + if 'pull_request' not in event['issue']: + event['issue']['pull_request'] = True # we don't care about the value # don't sync if user is our collaborator github = Github(os.environ['GITHUB_TOKEN']) repo = github.get_repo(os.environ['GITHUB_REPOSITORY']) - gh_issue = event["issue"] - is_pr = "pull_request" in gh_issue - if is_pr and repo.has_in_collaborators(gh_issue["user"]["login"]): - print("Skipping issue sync for Pull Request from collaborator") + gh_issue = event['issue'] + is_pr = 'pull_request' in gh_issue + if is_pr and repo.has_in_collaborators(gh_issue['user']['login']): + print('Skipping issue sync for Pull Request from collaborator') return action_handlers = { @@ -121,12 +133,12 @@ def main(): } if event_name not in action_handlers: - print("No handler for event '%s'. Skipping." % event_name) + print(f"No handler for event '{event_name}'. Skipping.") elif action not in action_handlers[event_name]: - print("No handler '%s' action '%s'. Skipping." % (event_name, action)) + print(f"No handler '{event_name}' action '{action}'. Skipping.") else: action_handlers[event_name][action](jira, event) -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/test_sync_to_jira.py b/test_sync_to_jira.py deleted file mode 100755 index ccd8cc3..0000000 --- a/test_sync_to_jira.py +++ /dev/null @@ -1,301 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright 2019 Espressif Systems (Shanghai) PTE LTD -# -# 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. -# -import jira -import github -import json -import sync_to_jira -import sync_issue -import os -import unittest -import unittest.mock -from unittest.mock import create_autospec -import tempfile - -MOCK_GITHUB_TOKEN = "iamagithubtoken" - - -def run_sync_issue(event_name, event, jira_issue=None): - """ - Run the 'sync_issue' main() function with supplied event (as Python dict), event name, and mocked JIRA PAI. - - If jira_issue is not None, this JIRA issue object will be - returned as the only result of a call to JIRA.search_issues(). - """ - try: - # dump the event data to a JSON file - event_file = tempfile.NamedTemporaryFile('w+', delete=False) - json.dump(event, event_file) - event_file.close() - - os.environ['GITHUB_EVENT_NAME'] = event_name - os.environ['GITHUB_EVENT_PATH'] = event_file.name - - os.environ['GITHUB_TOKEN'] = MOCK_GITHUB_TOKEN - os.environ['JIRA_PROJECT'] = 'TEST' - os.environ['JIRA_URL'] = 'https://test.test:88/' - os.environ['JIRA_USER'] = 'test_user' - os.environ['JIRA_PASS'] = 'test_pass' - os.environ['GITHUB_REPOSITORY'] = 'espressif/fake' - - github_class = create_autospec(github.Github) - - gh_repo_class = create_autospec(github.Repository.Repository) - - # tell repo.has_in_collaborators() to return False by default - github_class.return_value.get_repo.return_value.has_in_collaborators.return_value = False - - jira_class = create_autospec(jira.JIRA) - - # fake a issue_types response also - issue_type_bug = create_autospec(jira.resources.IssueType) - issue_type_bug.name = "Bug" - issue_type_bug.id = 5001 - issue_type_task = create_autospec(jira.resources.IssueType) - issue_type_task.name = "Task" - issue_type_task.id = 5002 - issue_type_new_feature = create_autospec(jira.resources.IssueType) - issue_type_task.name = "New Feature" - issue_type_task.id = 5003 - - jira_class.return_value.issue_types.return_value = [ - issue_type_bug, - issue_type_task, - issue_type_new_feature, - ] - - if jira_issue is not None: - jira_class.return_value.search_issues.return_value = [jira_issue] - remote_link = create_autospec(jira.resources.RemoteLink) - remote_link.globalId = event["issue"]["html_url"] - remote_link.relationship = "synced from" - remote_link.raw = {"object": { - "title": event["issue"]["title"], - "status": {}, - }} - jira_class.return_value.remote_links.return_value = [remote_link] - else: - jira_class.return_value.search_issues.return_value = [] - - sync_to_jira._JIRA = jira_class - sync_to_jira.Github = github_class - sync_issue.Github = github_class - sync_to_jira.main() - - return jira_class.return_value # mock JIRA object - - finally: - os.unlink(event_file.name) - - -class TestIssuesEvents(unittest.TestCase): - - def test_issue_opened(self): - issue = {"html_url": "https://github.com/espressif/fake/issues/3", - "repository_url": "https://github.com/espressif/fake", - "number": 3, - "title": "Test issue", - "body": "I am a new test issue\nabc\n测试\n", - "user": {"login": "testuser"}, - "labels": [{"name": "bug"}], - "state": "open", - } - event = {"action": "opened", - "issue": issue - } - - m_jira = run_sync_issue('issues', event) - - # Check that create_issue() was called with fields param resembling the GH issue - fields = m_jira.create_issue.call_args[0][0] - self.assertIn(issue["title"], fields["summary"]) - self.assertIn(issue["body"], fields["description"]) - self.assertIn(issue["html_url"], fields["description"]) - - # Mentions 'issue', no mention of 'pull request' - self.assertIn("issue", fields["description"]) - self.assertNotIn("pr", fields["summary"].lower()) - self.assertNotIn("pull request", fields["description"].lower()) - - # Check that add_remote_link() was called - rl_args = m_jira.add_remote_link.call_args[1] - self.assertEqual(m_jira.create_issue.return_value, rl_args["issue"]) - self.assertEqual(issue["html_url"], rl_args["globalId"]) - - # check that the github repo was updated via expected sequence of API calls - sync_issue.Github.assert_called_with(MOCK_GITHUB_TOKEN) - github_obj = sync_issue.Github.return_value - github_obj.get_repo.assert_called_with("espressif/fake") - repo_obj = github_obj.get_repo.return_value - repo_obj.get_issue.assert_called_with(issue["number"]) - issue_obj = repo_obj.get_issue.return_value - update_args = issue_obj.edit.call_args[1] - self.assertIn("title", update_args) - - def test_issue_closed(self): - m_jira = self._test_issue_simple_comment("closed") - - # check resolved was set - new_object = m_jira.remote_links.return_value[0].update.call_args[0][0] - new_status = new_object["status"] - self.assertEqual(True, new_status["resolved"]) - - def test_issue_deleted(self): - self._test_issue_simple_comment("deleted") - - def test_issue_reopened(self): - m_jira = self._test_issue_simple_comment("reopened") - - # check resolved was cleared - new_object = m_jira.remote_links.return_value[0].update.call_args[0][0] - new_status = new_object["status"] - self.assertEqual(False, new_status["resolved"]) - - def test_issue_edited(self): - issue = {"html_url": "https://github.com/espressif/fake/issues/11", - "repository_url": "https://github.com/espressif/fake", - "number": 11, - "title": "Edited issue", - "body": "Edited issue content goes here", - "user": {"login": "edituser"}, - "state": "open", - "labels": [], - } - - m_jira = self._test_issue_simple_comment("edited", issue) - - # check the update resembles the edited issue - m_issue = m_jira.search_issues.return_value[0] - - update_args = m_issue.update.call_args[1] - self.assertIn("description", update_args["fields"]) - self.assertIn("summary", update_args["fields"]) - self.assertIn(issue["title"], update_args["fields"]["summary"]) - - def _test_issue_simple_comment(self, action, gh_issue=None): - """ - Wrapper for the simple case of updating an issue (with 'action'). GitHub issue fields can be supplied, or generic ones will be used. - """ - if gh_issue is None: - gh_number = hash(action) % 43 - gh_issue = {"html_url": "https://github.com/espressif/fake/issues/%d" % gh_number, - "number": gh_number, - "title": "Test issue", - "body": "I am a test issue\nabc\n\n", - "user": {"login": "otheruser"}, - "labels": [{"name": "Type: New Feature"}], - "state": "closed" if action in ["closed", "deleted"] else "open", - } - event = {"action": action, - "issue": gh_issue - } - - m_issue = create_autospec(jira.Issue)(None, None) - jira_id = hash(action) % 1001 - m_issue.id = jira_id - - m_jira = run_sync_issue('issues', event, m_issue) - - # expect JIRA API added a comment about the action - comment_jira_id, comment = m_jira.add_comment.call_args[0] - self.assertEqual(jira_id, comment_jira_id) - self.assertIn(gh_issue["user"]["login"], comment) - self.assertIn(action, comment) - - return m_jira - - def test_pr_opened(self): - pr = {"html_url": "https://github.com/espressif/fake/pulls/4", - "base": {"repo": {"html_url": "https://github.com/espressif/fake"}}, - "number": 4, - "title": "Test issue", - "body": "I am a new Pull Request!\nabc\n测试\n", - "user": {"login": "testuser"}, - "labels": [{"name": "bug"}], - "state": "open", - } - event = {"action": "opened", - "pull_request": pr - } - - m_jira = run_sync_issue('pull_request', event) - - # Check that create_issue() mentions a PR not an issue - fields = m_jira.create_issue.call_args[0][0] - self.assertIn("PR", fields["summary"]) - self.assertIn("Pull Request", fields["description"]) - self.assertIn(pr["html_url"], fields["description"]) - - -class TestIssueCommentEvents(unittest.TestCase): - - def test_issue_comment_created(self): - self._test_issue_comment("created") - - def test_issue_comment_deleted(self): - self._test_issue_comment("deleted") - - def test_issue_comment_edited(self): - self._test_issue_comment("edited", extra_event_data={"changes": {"body": {"from": "I am the old comment body"}}}) - - def _test_issue_comment(self, action, gh_issue=None, gh_comment=None, extra_event_data={}): - """ - Wrapper for the simple case of an issue comment event (with 'action'). GitHub issue and comment fields can be supplied, or generic ones will be used. - """ - if gh_issue is None: - gh_number = hash(action) % 50 - gh_issue = {"html_url": "https://github.com/espressif/fake/issues/%d" % gh_number, - "repository_url": "https://github.com/espressif/fake", - "number": gh_number, - "title": "Test issue", - "body": "I am a test issue\nabc\n\n", - "user": {"login": "otheruser"}, - "labels": [] - } - if gh_comment is None: - gh_comment_id = hash(action) % 404 - gh_comment = {"html_url": gh_issue["html_url"] + "#" + str(gh_comment_id), - "repository_url": "https://github.com/espressif/fake", - "id": gh_comment_id, - "user": {"login": "commentuser"}, - "body": "ZOMG a comment!" - } - event = {"action": action, - "issue": gh_issue, - "comment": gh_comment - } - event.update(extra_event_data) - - m_issue = create_autospec(jira.Issue)(None, None) - jira_id = hash(action) % 1003 - m_issue.id = jira_id - m_issue.key = "FAKEFAKE-%d" % (hash(action) % 333,) - - m_jira = run_sync_issue('issue_comment', event, m_issue) - - # expect JIRA API added a comment about the action - comment_jira_id, comment = m_jira.add_comment.call_args[0] - self.assertEqual(jira_id, comment_jira_id) - self.assertIn(gh_comment["user"]["login"], comment) - self.assertIn(gh_comment["html_url"], comment) - if action != "deleted": - self.assertIn(gh_comment["body"], comment) # note: doesn't account for markdown2wiki - - return m_jira - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_sync_issue.py b/tests/test_sync_issue.py new file mode 100644 index 0000000..9f85f3f --- /dev/null +++ b/tests/test_sync_issue.py @@ -0,0 +1,99 @@ +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def setup_env(monkeypatch): + monkeypatch.setenv('GITHUB_TOKEN', 'fake-token') + monkeypatch.setenv('GITHUB_REPOSITORY', 'fake/repo') + + +@pytest.fixture(scope='module') +def github_client_mock(): + with patch('github.Github') as MockGithub: + mock_github = MockGithub.return_value + mock_repo = MagicMock() + mock_github.get_repo.return_value = mock_repo + yield mock_github, mock_repo + + +# Correct fixture to mock JIRA client +@pytest.fixture(scope='module') +def mock_jira_client(): + with patch('jira.JIRA') as MockJIRA: + mock_jira = MockJIRA.return_value + yield mock_jira + + +@pytest.fixture +def sync_issue_module(github_client_mock): + from importlib import reload + from sync_jira_actions import sync_issue + + reload(sync_issue) # Reload to apply the mocked Github client + return sync_issue + + +# Example test function +def test_handle_issue_opened_creates_jira_issue(sync_issue_module, github_client_mock): + _, mock_repo = github_client_mock + mock_jira_client = MagicMock() + mock_event = { + 'issue': { + 'number': 123, + 'title': 'New Issue', + 'body': 'Issue description here.', + 'user': {'login': 'user123'}, + 'labels': [], + 'html_url': 'https://github.com/user/repo/issues/123', + 'state': 'open', + } + } + + with ( + patch('sync_jira_actions.sync_issue._find_jira_issue', return_value=None) as mock_find_jira_issue, + patch('sync_jira_actions.sync_issue._create_jira_issue') as mock_create_jira_issue, + ): + sync_issue_module.handle_issue_opened(mock_jira_client, mock_event) + + mock_find_jira_issue.assert_called_once() + mock_create_jira_issue.assert_called_once() + + +def test_handle_issue_labeled_adds_label(sync_issue_module, github_client_mock, mock_jira_client): + # Setup + mock_github, mock_repo = github_client_mock + + mock_event = { + 'issue': { + 'number': 123, + 'title': 'Issue for Labeling', + 'body': 'Label me!', + 'user': {'login': 'user456'}, + 'labels': [{'name': 'bug'}], + 'html_url': 'https://github.com/user/repo/issues/123', + 'state': 'open', + }, + 'label': {'name': 'bug'}, + } + + # Adjusting the mock to behave more like a list that can be appended to + mock_jira_issue = MagicMock() + labels_list = ['existing-label'] # Starting with an existing label for demonstration + mock_jira_issue.fields.labels = labels_list + + def update_labels(fields=None): + if fields and 'labels' in fields: + labels_list.extend(fields['labels']) # Simulate adding new labels + + mock_jira_issue.update = MagicMock(side_effect=update_labels) + + with ( + patch('sync_jira_actions.sync_issue._find_jira_issue', return_value=mock_jira_issue), + patch('sync_jira_actions.sync_issue._get_jira_label', side_effect=lambda x: x['name']), + ): + sync_issue_module.handle_issue_labeled(mock_jira_client, mock_event) + + assert 'bug' in labels_list, "Label 'bug' was not added to the JIRA issue labels" diff --git a/tests/test_sync_pr.py b/tests/test_sync_pr.py new file mode 100644 index 0000000..6270ddf --- /dev/null +++ b/tests/test_sync_pr.py @@ -0,0 +1,72 @@ +import importlib +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + + +# Patch the GitHub client before importing modules that use it +@pytest.fixture(autouse=True) +def mock_env_vars(monkeypatch): + monkeypatch.setenv('GITHUB_TOKEN', 'fake-token') + monkeypatch.setenv('GITHUB_REPOSITORY', 'fake/repo') + + +@pytest.fixture +def mock_github(): + with patch('github.Github') as MockGithub: + mock_repo = MagicMock() + mock_pr = MagicMock() + mock_pr.number = 1 + mock_pr.title = 'Test PR' + mock_pr.html_url = 'http://example.com/testpr' + mock_pr.user.login = 'testuser' + mock_pr.labels = [] + mock_pr.state = 'open' + mock_pr.body = 'Test body' + mock_repo.get_pulls.return_value = [mock_pr] + mock_repo.has_in_collaborators.return_value = False + + MockGithub.return_value.get_repo.return_value = mock_repo + yield mock_repo + + +@pytest.fixture +def sync_pr_module(mock_github): + # Import the module from the sync_jira_actions directory + import sys + + sys.path.insert(0, 'sync_jira_actions') # Add sync_jira_actions directory to the Python path + import sync_pr + + # Reload the module to ensure the mock is applied + importlib.reload(sync_pr) + # Return the reloaded module + return sync_pr + + +@pytest.fixture +def mock_sync_issue(): + with ( + patch('sync_pr._create_jira_issue') as mock_create_jira_issue, + patch('sync_pr._find_jira_issue', return_value=None) as mock_find_jira_issue, + ): + yield mock_create_jira_issue, mock_find_jira_issue + + +def test_sync_remain_prs(sync_pr_module, mock_sync_issue, mock_github): + mock_jira = MagicMock() + mock_create_jira_issue, mock_find_jira_issue = mock_sync_issue + + # Use the function from the reloaded module + sync_pr_module.sync_remain_prs(mock_jira) + + # Verify _find_jira_issue was called once with the mock_jira client and the PR data + assert mock_find_jira_issue.call_count == 1 + + # Verify _create_jira_issue was called once since no corresponding JIRA issue was found + assert mock_create_jira_issue.call_count == 1 + + # Example of verifying call arguments (simplified) + call_args = mock_create_jira_issue.call_args + assert 'Test PR' in call_args[0][1]['title'], 'PR title does not match expected value' diff --git a/tests/test_sync_to_jira.py b/tests/test_sync_to_jira.py new file mode 100644 index 0000000..8ff60d3 --- /dev/null +++ b/tests/test_sync_to_jira.py @@ -0,0 +1,61 @@ +import json +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def mock_environment(tmp_path, monkeypatch): + event_file = tmp_path / 'event.json' + monkeypatch.setenv('GITHUB_REPOSITORY', 'espressif/esp-idf') + monkeypatch.setenv('GITHUB_TOKEN', 'fake-token') + monkeypatch.setenv('GITHUB_EVENT_PATH', str(event_file)) + monkeypatch.setenv('JIRA_URL', 'https://jira.example.com') + monkeypatch.setenv('JIRA_USER', 'user') + monkeypatch.setenv('JIRA_PASS', 'pass') + return event_file + + +@pytest.fixture +def sync_to_jira_main(monkeypatch): + monkeypatch.setattr('github.Github', MagicMock()) + monkeypatch.setattr('jira.JIRA', MagicMock()) + + # Import the main function dynamically after applying mocks + from sync_jira_actions.sync_to_jira import main as dynamically_imported_main + + return dynamically_imported_main + + +def test_not_running_in_github_action_context(capsys, sync_to_jira_main, monkeypatch): + monkeypatch.delenv('GITHUB_REPOSITORY', raising=False) + sync_to_jira_main() + captured = capsys.readouterr() + assert 'Not running in GitHub action context, nothing to do' in captured.out + + +def test_not_espressif_repo(capsys, sync_to_jira_main, monkeypatch): + monkeypatch.setenv('GITHUB_REPOSITORY', 'other/repo') + sync_to_jira_main() + captured = capsys.readouterr() + assert 'Not an Espressif repo, nothing to sync to JIRA' in captured.out + + +def test_handle_issue_opened_event(mock_environment, sync_to_jira_main, monkeypatch): + event_data = { + 'action': 'opened', + 'issue': { + 'number': 1, + 'title': 'Test issue', + 'body': 'This is a test issue', + 'user': {'login': 'testuser'}, + 'html_url': 'https://github.com/espressif/esp-idf/issues/1', + }, + } + mock_environment.write_text(json.dumps(event_data)) + monkeypatch.setenv('GITHUB_EVENT_NAME', 'issues') + + with patch('sync_jira_actions.sync_to_jira.handle_issue_opened') as mock_handle_issue_opened: + sync_to_jira_main() + mock_handle_issue_opened.assert_called_once()