From a7bcd06d72c5ea62b4b614d85bdbc87e6a6de19d Mon Sep 17 00:00:00 2001 From: YanxuanLiu Date: Thu, 13 Aug 2026 11:47:41 +0800 Subject: [PATCH 1/5] Auto-set PR Roadmap from pom.xml Signed-off-by: YanxuanLiu --- add-to-project/action.yml | 44 +--- add-to-project/add_to_project.py | 366 +++++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+), 40 deletions(-) create mode 100644 add-to-project/add_to_project.py diff --git a/add-to-project/action.yml b/add-to-project/action.yml index f879f05..4293b42 100644 --- a/add-to-project/action.yml +++ b/add-to-project/action.yml @@ -16,7 +16,7 @@ name: "Add to Project" description: "Add new issue or pull request to the project" inputs: token: - description: "GitHub token" + description: "GitHub token with project write and repository read access" required: true type: string project-url: @@ -28,44 +28,8 @@ runs: using: "composite" steps: - name: Adding to project - uses: actions/github-script@v8 + shell: bash env: + GH_TOKEN: ${{ inputs.token }} PROJECT_URL: ${{ inputs.project-url }} - with: - github-token: ${{ inputs.token }} - script: | - const projectUrl = process.env.PROJECT_URL; - const match = projectUrl.match(/\/(?orgs|users)\/(?[^/]+)\/projects\/(?\d+)/); - if (!match) { - core.setFailed(`Invalid project URL: ${projectUrl}`); - return; - } - const { ownerType, ownerName, projectNumber } = match.groups; - - // Get the project node ID - const ownerField = ownerType === 'orgs' ? 'organization' : 'user'; - const { [ownerField]: owner } = await github.graphql(` - query($ownerName: String!, $projectNumber: Int!) { - ${ownerField}(login: $ownerName) { - projectV2(number: $projectNumber) { id } - } - } - `, { ownerName, projectNumber: parseInt(projectNumber) }); - const projectId = owner.projectV2.id; - - // Get the content node ID (issue or pull request) - const contentId = context.payload.issue?.node_id || context.payload.pull_request?.node_id; - if (!contentId) { - core.setFailed('No issue or pull request found in event payload.'); - return; - } - - await github.graphql(` - mutation($projectId: ID!, $contentId: ID!) { - addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { - item { id } - } - } - `, { projectId, contentId }); - - core.info(`Added to project ${projectUrl}`); + run: python3 "${{ github.action_path }}/add_to_project.py" diff --git a/add-to-project/add_to_project.py b/add-to-project/add_to_project.py new file mode 100644 index 0000000..57379b9 --- /dev/null +++ b/add-to-project/add_to_project.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2026, NVIDIA CORPORATION. +# +# 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. + +"""Add an issue or pull request to a GitHub Project and set its Roadmap.""" + +import base64 +import binascii +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +import xml.etree.ElementTree as ElementTree + + +DEFAULT_PROJECT_URL = "https://github.com/orgs/NVIDIA/projects/4" +MAVEN_NAMESPACE = "http://maven.apache.org/POM/4.0.0" +ROADMAP_REPOSITORIES = { + "NVIDIA/cudf-spark", + "NVIDIA/cudf-spark-jni", +} +VERSION_PATTERN = re.compile( + r"^(\d{2}\.(?:0[1-9]|1[0-2]))(?:\.\d+)*(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?$" +) + + +class AutomationError(Exception): + """A user-facing automation failure.""" + + +class NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """Do not forward the project token through an API redirect.""" + + def redirect_request(self, request, response, code, message, headers, new_url): + raise AutomationError("GitHub API unexpectedly redirected the request.") + + +class GitHubClient: + """Small GitHub REST and GraphQL client using the Python standard library.""" + + def __init__(self, token, api_url="https://api.github.com", opener=None): + self.api_url = api_url.rstrip("/") + self.opener = opener or urllib.request.build_opener(NoRedirectHandler()) + self.headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "spark-rapids-common-add-to-project", + "X-GitHub-Api-Version": "2022-11-28", + } + + def request(self, method, path, payload=None, params=None): + url = f"{self.api_url}/{path.lstrip('/')}" + if params: + url += "?" + urllib.parse.urlencode(params) + request = urllib.request.Request( + url, + data=None if payload is None else json.dumps(payload).encode(), + method=method, + headers=self.headers, + ) + try: + with self.opener.open(request, timeout=30) as response: + return json.loads(response.read().decode()) + except urllib.error.HTTPError as error: + raise AutomationError( + f"GitHub API returned HTTP {error.code}: {error.reason}" + ) from error + except (urllib.error.URLError, TimeoutError, OSError) as error: + raise AutomationError(f"GitHub API request failed: {error}") from error + except (UnicodeError, json.JSONDecodeError) as error: + raise AutomationError("GitHub API returned invalid JSON.") from error + + def graphql(self, query, variables): + response = self.request( + "POST", "graphql", {"query": query, "variables": variables} + ) + errors = response.get("errors") if isinstance(response, dict) else None + if errors: + messages = "; ".join(str(error.get("message", error)) for error in errors) + raise AutomationError(f"GitHub GraphQL request failed: {messages}") + data = response.get("data") if isinstance(response, dict) else None + if not isinstance(data, dict): + raise AutomationError("GitHub GraphQL response did not contain data.") + return data + + def get(self, path, params=None): + return self.request("GET", path, params=params) + + +def require(value, message): + if value is None or value == "": + raise AutomationError(message) + return value + + +def dig(value, *keys): + for key in keys: + if not isinstance(value, dict): + return None + value = value.get(key) + return value + + +def extract_project_version(pom_xml): + """Return the YY.MM release from the single direct project version.""" + try: + root = ElementTree.fromstring(pom_xml) + except ElementTree.ParseError as error: + raise AutomationError(f"pom.xml is malformed XML: {error}") from error + + if root.tag == "project": + version_tag = "version" + elif root.tag == f"{{{MAVEN_NAMESPACE}}}project": + version_tag = f"{{{MAVEN_NAMESPACE}}}version" + else: + raise AutomationError("pom.xml does not have a Maven project root element.") + + versions = root.findall(version_tag) + if len(versions) != 1: + raise AutomationError( + "pom.xml must contain exactly one direct /project/version value." + ) + if list(versions[0]): + raise AutomationError("The project version contains nested XML elements.") + raw_version = (versions[0].text or "").strip() + match = VERSION_PATTERN.fullmatch(raw_version) + if not match: + raise AutomationError( + f"The project version {raw_version!r} is not an unambiguous YY.MM release." + ) + return match.group(1) + + +def get_project(client, project_url): + match = re.fullmatch( + r"https://github\.com/(?Porgs|users)/(?P[^/]+)/" + r"projects/(?P\d+)/?", + project_url, + ) + if not match: + raise AutomationError(f"Invalid project URL: {project_url}") + owner = "organization" if match["kind"] == "orgs" else "user" + data = client.graphql( + f""" + query Project($login: String!, $number: Int!, $field: String!) {{ + {owner}(login: $login) {{ + projectV2(number: $number) {{ + id + field(name: $field) {{ + __typename + ... on ProjectV2SingleSelectField {{ + id + options {{ id name }} + }} + }} + }} + }} + }} + """, + { + "login": match["login"], + "number": int(match["number"]), + "field": "Roadmap", + }, + ) + project = dig(data, owner, "projectV2") + if not isinstance(project, dict) or not project.get("id"): + raise AutomationError(f"Project not found: {project_url}") + return project + + +def add_project_item(client, project_id, content_id): + data = client.graphql( + """ + mutation AddItem($project: ID!, $content: ID!, $field: String!) { + addProjectV2ItemById(input: {projectId: $project, contentId: $content}) { + item { + id + content { + ... on PullRequest { baseRefName baseRefOid } + } + fieldValueByName(name: $field) { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + """, + {"project": project_id, "content": content_id, "field": "Roadmap"}, + ) + item = dig(data, "addProjectV2ItemById", "item") + if not isinstance(item, dict) or not item.get("id"): + raise AutomationError("GitHub did not return the added project item.") + return item + + +def get_roadmap(client, item_id): + data = client.graphql( + """ + query Roadmap($item: ID!, $field: String!) { + item: node(id: $item) { + ... on ProjectV2Item { + fieldValueByName(name: $field) { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + """, + {"item": item_id, "field": "Roadmap"}, + ) + item = data.get("item") + if not isinstance(item, dict): + raise AutomationError("GitHub could not refresh the project item.") + return item.get("fieldValueByName") + + +def set_roadmap(client, project_id, item_id, field_id, option_id): + data = client.graphql( + """ + mutation SetRoadmap($project: ID!, $item: ID!, $field: ID!, $option: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $project, + itemId: $item, + fieldId: $field, + value: {singleSelectOptionId: $option} + }) { + projectV2Item { id } + } + } + """, + { + "project": project_id, + "item": item_id, + "field": field_id, + "option": option_id, + }, + ) + if not dig(data, "updateProjectV2ItemFieldValue", "projectV2Item", "id"): + raise AutomationError("GitHub did not confirm the Roadmap update.") + + +def read_target_pom(client, repository, item): + base = item.get("content") + if not isinstance(base, dict): + raise AutomationError("GitHub did not return the pull request target branch.") + base_ref = require( + base.get("baseRefName"), "The pull request target branch is missing." + ) + base_sha = require(base.get("baseRefOid"), "The pull request target SHA is missing.") + + pom = client.get( + f"repos/{repository}/contents/pom.xml", {"ref": base_sha} + ) + if not isinstance(pom, dict): + raise AutomationError("The target branch root pom.xml could not be read.") + content = pom.get("content") + if pom.get("type") != "file" or pom.get("encoding") != "base64" or not content: + raise AutomationError("The target branch root pom.xml could not be read.") + try: + pom_xml = base64.b64decode("".join(content.split()), validate=True).decode() + except (AttributeError, binascii.Error, UnicodeError) as error: + raise AutomationError("The target branch pom.xml content is invalid.") from error + return base_ref, base_sha, pom_xml + + +def populate_roadmap(client, repository, pull_request, project, item): + field = project.get("field") + if not isinstance(field, dict) or field.get("__typename") != ( + "ProjectV2SingleSelectField" + ): + raise AutomationError("Roadmap is missing or is not a single-select field.") + + current = item.get("fieldValueByName") + if current is not None: + print(f"Roadmap is already set to {current.get('name')!r}; preserving it.") + return + + base_ref, base_sha, pom_xml = read_target_pom(client, repository, item) + roadmap = extract_project_version(pom_xml) + options = field.get("options") if isinstance(field.get("options"), list) else [] + matches = [option for option in options if option.get("name") == roadmap] + if len(matches) != 1: + raise AutomationError( + f"Roadmap must contain exactly one {roadmap!r} option." + ) + + latest = get_roadmap(client, item["id"]) + if latest is not None: + print( + f"Roadmap was set to {latest.get('name')!r} while this action was " + "running; preserving it." + ) + return + + set_roadmap( + client, + project["id"], + item["id"], + require(field.get("id"), "Roadmap field ID is missing."), + require(matches[0].get("id"), "Roadmap option ID is missing."), + ) + print(f"Set Roadmap to {roadmap!r} from pom.xml on target branch {base_ref} ({base_sha}).") + + +def run(client, event, project_url, repository): + project = get_project(client, project_url) + content = event.get("issue") or event.get("pull_request") or {} + content_id = require( + content.get("node_id"), "No issue or pull request found in event payload." + ) + item = add_project_item(client, project["id"], content_id) + print(f"Added to project {project_url}") + + pull_request = event.get("pull_request") + if pull_request and repository in ROADMAP_REPOSITORIES: + try: + populate_roadmap(client, repository, pull_request, project, item) + except AutomationError as error: + number = pull_request.get("number", "unknown") + raise AutomationError( + f"Roadmap automation failed for {repository}#{number}: {error}" + ) from error + + +def main(environ=None, client=None): + environ = os.environ if environ is None else environ + try: + token = require(environ.get("GH_TOKEN"), "GitHub token is missing.") + event_path = require( + environ.get("GITHUB_EVENT_PATH"), "GITHUB_EVENT_PATH is missing." + ) + with open(event_path, encoding="utf-8") as event_file: + event = json.load(event_file) + repository = dig(event, "repository", "full_name") or require( + environ.get("GITHUB_REPOSITORY"), "GitHub repository is missing." + ) + client = client or GitHubClient( + token, environ.get("GITHUB_API_URL", "https://api.github.com") + ) + run(client, event, environ.get("PROJECT_URL", DEFAULT_PROJECT_URL), repository) + return 0 + except (AutomationError, OSError, json.JSONDecodeError) as error: + message = str(error).replace("%", "%25").replace("\r", "%0D") + print(f"::error::{message.replace(chr(10), '%0A')}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From 53b957404b5b4909a974cc7493412a02a74dbc43 Mon Sep 17 00:00:00 2001 From: YanxuanLiu Date: Thu, 13 Aug 2026 16:47:58 +0800 Subject: [PATCH 2/5] Remove unused pull request argument Signed-off-by: YanxuanLiu --- add-to-project/add_to_project.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/add-to-project/add_to_project.py b/add-to-project/add_to_project.py index 57379b9..04685b4 100644 --- a/add-to-project/add_to_project.py +++ b/add-to-project/add_to_project.py @@ -280,7 +280,7 @@ def read_target_pom(client, repository, item): return base_ref, base_sha, pom_xml -def populate_roadmap(client, repository, pull_request, project, item): +def populate_roadmap(client, repository, project, item): field = project.get("field") if not isinstance(field, dict) or field.get("__typename") != ( "ProjectV2SingleSelectField" @@ -331,7 +331,7 @@ def run(client, event, project_url, repository): pull_request = event.get("pull_request") if pull_request and repository in ROADMAP_REPOSITORIES: try: - populate_roadmap(client, repository, pull_request, project, item) + populate_roadmap(client, repository, project, item) except AutomationError as error: number = pull_request.get("number", "unknown") raise AutomationError( From b479bd99f175484ca5bbe42fb269e6ce685bc50e Mon Sep 17 00:00:00 2001 From: YanxuanLiu Date: Fri, 14 Aug 2026 14:06:14 +0800 Subject: [PATCH 3/5] Set Roadmap when pull requests merge Signed-off-by: YanxuanLiu --- add-to-project/add_to_project.py | 36 ++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/add-to-project/add_to_project.py b/add-to-project/add_to_project.py index 04685b4..186443a 100644 --- a/add-to-project/add_to_project.py +++ b/add-to-project/add_to_project.py @@ -192,9 +192,6 @@ def add_project_item(client, project_id, content_id): addProjectV2ItemById(input: {projectId: $project, contentId: $content}) { item { id - content { - ... on PullRequest { baseRefName baseRefOid } - } fieldValueByName(name: $field) { ... on ProjectV2ItemFieldSingleSelectValue { name } } @@ -256,17 +253,17 @@ def set_roadmap(client, project_id, item_id, field_id, option_id): raise AutomationError("GitHub did not confirm the Roadmap update.") -def read_target_pom(client, repository, item): - base = item.get("content") - if not isinstance(base, dict): - raise AutomationError("GitHub did not return the pull request target branch.") +def read_target_pom(client, repository, pull_request): base_ref = require( - base.get("baseRefName"), "The pull request target branch is missing." + dig(pull_request, "base", "ref"), "The pull request target branch is missing." + ) + merge_sha = require( + pull_request.get("merge_commit_sha"), + "The pull request merged result SHA is missing.", ) - base_sha = require(base.get("baseRefOid"), "The pull request target SHA is missing.") pom = client.get( - f"repos/{repository}/contents/pom.xml", {"ref": base_sha} + f"repos/{repository}/contents/pom.xml", {"ref": merge_sha} ) if not isinstance(pom, dict): raise AutomationError("The target branch root pom.xml could not be read.") @@ -277,10 +274,10 @@ def read_target_pom(client, repository, item): pom_xml = base64.b64decode("".join(content.split()), validate=True).decode() except (AttributeError, binascii.Error, UnicodeError) as error: raise AutomationError("The target branch pom.xml content is invalid.") from error - return base_ref, base_sha, pom_xml + return base_ref, merge_sha, pom_xml -def populate_roadmap(client, repository, project, item): +def populate_roadmap(client, repository, project, item, pull_request): field = project.get("field") if not isinstance(field, dict) or field.get("__typename") != ( "ProjectV2SingleSelectField" @@ -292,7 +289,7 @@ def populate_roadmap(client, repository, project, item): print(f"Roadmap is already set to {current.get('name')!r}; preserving it.") return - base_ref, base_sha, pom_xml = read_target_pom(client, repository, item) + base_ref, merge_sha, pom_xml = read_target_pom(client, repository, pull_request) roadmap = extract_project_version(pom_xml) options = field.get("options") if isinstance(field.get("options"), list) else [] matches = [option for option in options if option.get("name") == roadmap] @@ -316,7 +313,10 @@ def populate_roadmap(client, repository, project, item): require(field.get("id"), "Roadmap field ID is missing."), require(matches[0].get("id"), "Roadmap option ID is missing."), ) - print(f"Set Roadmap to {roadmap!r} from pom.xml on target branch {base_ref} ({base_sha}).") + print( + f"Set Roadmap to {roadmap!r} from pom.xml on merged target branch " + f"{base_ref} ({merge_sha})." + ) def run(client, event, project_url, repository): @@ -329,9 +329,13 @@ def run(client, event, project_url, repository): print(f"Added to project {project_url}") pull_request = event.get("pull_request") - if pull_request and repository in ROADMAP_REPOSITORIES: + if ( + pull_request + and pull_request.get("merged") is True + and repository in ROADMAP_REPOSITORIES + ): try: - populate_roadmap(client, repository, project, item) + populate_roadmap(client, repository, project, item, pull_request) except AutomationError as error: number = pull_request.get("number", "unknown") raise AutomationError( From 8f2200ea20ef0e302bf716623fab87e7e5db3cd0 Mon Sep 17 00:00:00 2001 From: YanxuanLiu Date: Fri, 14 Aug 2026 16:12:46 +0800 Subject: [PATCH 4/5] Handle existing project items on merge Signed-off-by: YanxuanLiu --- .github/workflows/add-to-project-test.yml | 38 ++++ add-to-project/README.md | 6 +- add-to-project/add_to_project.py | 75 ++++++- add-to-project/test_add_to_project.py | 235 ++++++++++++++++++++++ 4 files changed, 346 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/add-to-project-test.yml create mode 100644 add-to-project/test_add_to_project.py diff --git a/.github/workflows/add-to-project-test.yml b/.github/workflows/add-to-project-test.yml new file mode 100644 index 0000000..651a9a2 --- /dev/null +++ b/.github/workflows/add-to-project-test.yml @@ -0,0 +1,38 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. +# +# 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. + +name: add-to-project test + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'add-to-project/**' + - '.github/workflows/add-to-project-test.yml' + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Run tests + run: python3 -m unittest discover -s add-to-project -p 'test_*.py' -v diff --git a/add-to-project/README.md b/add-to-project/README.md index 781bd3e..14a60d6 100644 --- a/add-to-project/README.md +++ b/add-to-project/README.md @@ -1,10 +1,12 @@ # add-to-project -This composite action to add new issue and pull request to the project. +This composite action adds new issues and pull requests to the project. For +merged pull requests in cudf-spark and cudf-spark-jni, it also sets an empty +Roadmap field from the target branch root `pom.xml`. ## Inputs -- `token` (required): GitHub token that has write access to the project +- `token` (required): GitHub token with project write and repository read access ## Usage diff --git a/add-to-project/add_to_project.py b/add-to-project/add_to_project.py index 186443a..f28d78c 100644 --- a/add-to-project/add_to_project.py +++ b/add-to-project/add_to_project.py @@ -147,7 +147,7 @@ def extract_project_version(pom_xml): return match.group(1) -def get_project(client, project_url): +def get_project(client, project_url, content_id): match = re.fullmatch( r"https://github\.com/(?Porgs|users)/(?P[^/]+)/" r"projects/(?P\d+)/?", @@ -158,7 +158,9 @@ def get_project(client, project_url): owner = "organization" if match["kind"] == "orgs" else "user" data = client.graphql( f""" - query Project($login: String!, $number: Int!, $field: String!) {{ + query Project( + $login: String!, $number: Int!, $field: String!, $content: ID! + ) {{ {owner}(login: $login) {{ projectV2(number: $number) {{ id @@ -171,18 +173,68 @@ def get_project(client, project_url): }} }} }} + content: node(id: $content) {{ + __typename + ... on Issue {{ + projectItems(first: 100, includeArchived: true) {{ + nodes {{ ...ProjectItem }} + pageInfo {{ hasNextPage }} + }} + }} + ... on PullRequest {{ + projectItems(first: 100, includeArchived: true) {{ + nodes {{ ...ProjectItem }} + pageInfo {{ hasNextPage }} + }} + }} + }} + }} + fragment ProjectItem on ProjectV2Item {{ + id + project {{ id }} + fieldValueByName(name: $field) {{ + ... on ProjectV2ItemFieldSingleSelectValue {{ name }} + }} }} """, { "login": match["login"], "number": int(match["number"]), "field": "Roadmap", + "content": content_id, }, ) project = dig(data, owner, "projectV2") if not isinstance(project, dict) or not project.get("id"): raise AutomationError(f"Project not found: {project_url}") - return project + + content = data.get("content") + if not isinstance(content, dict) or content.get("__typename") not in ( + "Issue", + "PullRequest", + ): + raise AutomationError("GitHub did not return the issue or pull request.") + project_items = content.get("projectItems") + nodes = project_items.get("nodes") if isinstance(project_items, dict) else None + has_next_page = dig(project_items, "pageInfo", "hasNextPage") + if not isinstance(nodes, list) or not isinstance(has_next_page, bool): + raise AutomationError("GitHub did not return the content's project items.") + + matches = [ + item + for item in nodes + if isinstance(item, dict) and dig(item, "project", "id") == project["id"] + ] + if len(matches) > 1: + raise AutomationError("The content has multiple items in the project.") + if matches and not matches[0].get("id"): + raise AutomationError("The existing project item ID is missing.") + if not matches and has_next_page: + raise AutomationError( + "The content belongs to more than 100 projects; its item could not be " + "identified safely." + ) + return project, matches[0] if matches else None def add_project_item(client, project_id, content_id): @@ -320,13 +372,24 @@ def populate_roadmap(client, repository, project, item, pull_request): def run(client, event, project_url, repository): - project = get_project(client, project_url) content = event.get("issue") or event.get("pull_request") or {} content_id = require( content.get("node_id"), "No issue or pull request found in event payload." ) - item = add_project_item(client, project["id"], content_id) - print(f"Added to project {project_url}") + project, item = get_project(client, project_url, content_id) + added = False + if item is None: + try: + item = add_project_item(client, project["id"], content_id) + added = True + except AutomationError: + project, item = get_project(client, project_url, content_id) + if item is None: + raise + if added: + print(f"Added to project {project_url}") + else: + print(f"Already in project {project_url}") pull_request = event.get("pull_request") if ( diff --git a/add-to-project/test_add_to_project.py b/add-to-project/test_add_to_project.py new file mode 100644 index 0000000..7dd74dd --- /dev/null +++ b/add-to-project/test_add_to_project.py @@ -0,0 +1,235 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. +# +# 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 base64 +import importlib.util +import pathlib +import unittest + + +MODULE_PATH = pathlib.Path(__file__).with_name("add_to_project.py") +SPEC = importlib.util.spec_from_file_location("add_to_project", MODULE_PATH) +add_to_project = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(add_to_project) + +AutomationError = add_to_project.AutomationError +run = add_to_project.run + +PROJECT_URL = "https://github.com/orgs/NVIDIA/projects/4" +DEFAULT_POM = """\ + + 4.0.0 + 26.08.0-SNAPSHOT + +""" + + +class FakeGitHubClient: + def __init__( + self, + item_exists=True, + roadmap=None, + latest=None, + pom=DEFAULT_POM, + has_next_page=False, + content_type="PullRequest", + item_exists_after_add_error=None, + ): + self.item_exists = item_exists + self.roadmap = roadmap + self.latest = latest + self.pom = pom + self.has_next_page = has_next_page + self.content_type = content_type + self.item_exists_after_add_error = item_exists_after_add_error + self.graphql_calls = [] + self.get_calls = [] + + @property + def operations(self): + return [operation for operation, _ in self.graphql_calls] + + def graphql(self, query, variables): + operation = next( + name + for name in ("Project", "AddItem", "SetRoadmap", "Roadmap") + if f"{name}(" in query + ) + self.graphql_calls.append((operation, variables)) + if operation == "Project": + item = { + "id": "ITEM", + "project": {"id": "PROJECT"}, + "fieldValueByName": self.roadmap, + } + return { + "organization": { + "projectV2": { + "id": "PROJECT", + "field": { + "__typename": "ProjectV2SingleSelectField", + "id": "ROADMAP_FIELD", + "options": [{"id": "OPTION_2608", "name": "26.08"}], + }, + } + }, + "content": { + "__typename": self.content_type, + "projectItems": { + "nodes": [item] if self.item_exists else [], + "pageInfo": {"hasNextPage": self.has_next_page}, + }, + }, + } + if operation == "AddItem": + if self.item_exists_after_add_error is not None: + self.item_exists = self.item_exists_after_add_error + raise AutomationError("add failed") + return { + "addProjectV2ItemById": { + "item": {"id": "ITEM", "fieldValueByName": None} + } + } + if operation == "Roadmap": + return {"item": {"fieldValueByName": self.latest}} + return {"updateProjectV2ItemFieldValue": {"projectV2Item": {"id": "ITEM"}}} + + def get(self, path, params=None): + self.get_calls.append((path, params)) + return { + "type": "file", + "encoding": "base64", + "content": base64.b64encode(self.pom.encode()).decode(), + } + + +def merged_event(repository): + return { + "pull_request": { + "node_id": "PR", + "number": 1, + "merged": True, + "base": {"ref": "branch-26.08"}, + "merge_commit_sha": "MERGE_SHA", + }, + "repository": {"full_name": repository}, + } + + +class AddToProjectTest(unittest.TestCase): + def test_merged_existing_item_sets_roadmap_in_both_repositories(self): + for repository in ("NVIDIA/cudf-spark", "NVIDIA/cudf-spark-jni"): + with self.subTest(repository=repository): + client = FakeGitHubClient() + run(client, merged_event(repository), PROJECT_URL, repository) + + self.assertEqual( + ["Project", "Roadmap", "SetRoadmap"], client.operations + ) + self.assertEqual( + [(f"repos/{repository}/contents/pom.xml", {"ref": "MERGE_SHA"})], + client.get_calls, + ) + set_variables = client.graphql_calls[-1][1] + self.assertEqual("OPTION_2608", set_variables["option"]) + + def test_merged_non_target_repository_does_not_set_roadmap(self): + repository = "NVIDIA/other" + client = FakeGitHubClient() + + run(client, merged_event(repository), PROJECT_URL, repository) + + self.assertEqual(["Project"], client.operations) + self.assertEqual([], client.get_calls) + + def test_existing_roadmap_is_preserved_without_reading_pom(self): + repository = "NVIDIA/cudf-spark" + client = FakeGitHubClient(roadmap={"name": "27.10"}) + + run(client, merged_event(repository), PROJECT_URL, repository) + + self.assertEqual(["Project"], client.operations) + self.assertEqual([], client.get_calls) + + def test_opened_content_is_added_without_setting_roadmap(self): + for content_type, key in (("Issue", "issue"), ("PullRequest", "pull_request")): + with self.subTest(content_type=content_type): + client = FakeGitHubClient( + item_exists=False, content_type=content_type + ) + event = {key: {"node_id": "CONTENT", "merged": False}} + + run(client, event, PROJECT_URL, "NVIDIA/cudf-spark") + + self.assertEqual(["Project", "AddItem"], client.operations) + self.assertEqual([], client.get_calls) + + def test_ambiguous_versions_never_update_roadmap(self): + invalid_poms = ( + "", + "26.08.026.10.0", + "${revision}", + ) + repository = "NVIDIA/cudf-spark" + for pom in invalid_poms: + with self.subTest(pom=pom): + client = FakeGitHubClient(pom=pom) + + with self.assertRaises(AutomationError): + run(client, merged_event(repository), PROJECT_URL, repository) + + self.assertNotIn("SetRoadmap", client.operations) + + def test_roadmap_set_while_running_is_preserved(self): + repository = "NVIDIA/cudf-spark" + client = FakeGitHubClient(latest={"name": "27.10"}) + + run(client, merged_event(repository), PROJECT_URL, repository) + + self.assertEqual(["Project", "Roadmap"], client.operations) + + def test_hidden_existing_item_fails_without_adding(self): + client = FakeGitHubClient(item_exists=False, has_next_page=True) + + with self.assertRaisesRegex(AutomationError, "more than 100 projects"): + run(client, {"pull_request": {"node_id": "PR"}}, PROJECT_URL, "repo") + + self.assertEqual(["Project"], client.operations) + + def test_duplicate_add_race_recovers_existing_item(self): + repository = "NVIDIA/cudf-spark" + client = FakeGitHubClient( + item_exists=False, item_exists_after_add_error=True + ) + + run(client, merged_event(repository), PROJECT_URL, repository) + + self.assertEqual( + ["Project", "AddItem", "Project", "Roadmap", "SetRoadmap"], + client.operations, + ) + + def test_add_failure_is_preserved_when_item_is_still_missing(self): + client = FakeGitHubClient( + item_exists=False, item_exists_after_add_error=False + ) + + with self.assertRaisesRegex(AutomationError, "add failed"): + run(client, {"issue": {"node_id": "ISSUE"}}, PROJECT_URL, "repo") + + self.assertEqual(["Project", "AddItem", "Project"], client.operations) + + +if __name__ == "__main__": + unittest.main() From 1defcec9b0c6910e611e8b4e84f52918b6dec4ff Mon Sep 17 00:00:00 2001 From: YanxuanLiu Date: Fri, 14 Aug 2026 16:22:06 +0800 Subject: [PATCH 5/5] Keep Roadmap fix minimal Signed-off-by: YanxuanLiu --- .github/workflows/add-to-project-test.yml | 38 ---- add-to-project/README.md | 6 +- add-to-project/test_add_to_project.py | 235 ---------------------- 3 files changed, 2 insertions(+), 277 deletions(-) delete mode 100644 .github/workflows/add-to-project-test.yml delete mode 100644 add-to-project/test_add_to_project.py diff --git a/.github/workflows/add-to-project-test.yml b/.github/workflows/add-to-project-test.yml deleted file mode 100644 index 651a9a2..0000000 --- a/.github/workflows/add-to-project-test.yml +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) 2026, NVIDIA CORPORATION. -# -# 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. - -name: add-to-project test - -on: - pull_request: - types: [opened, synchronize, reopened] - paths: - - 'add-to-project/**' - - '.github/workflows/add-to-project-test.yml' - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: Run tests - run: python3 -m unittest discover -s add-to-project -p 'test_*.py' -v diff --git a/add-to-project/README.md b/add-to-project/README.md index 14a60d6..781bd3e 100644 --- a/add-to-project/README.md +++ b/add-to-project/README.md @@ -1,12 +1,10 @@ # add-to-project -This composite action adds new issues and pull requests to the project. For -merged pull requests in cudf-spark and cudf-spark-jni, it also sets an empty -Roadmap field from the target branch root `pom.xml`. +This composite action to add new issue and pull request to the project. ## Inputs -- `token` (required): GitHub token with project write and repository read access +- `token` (required): GitHub token that has write access to the project ## Usage diff --git a/add-to-project/test_add_to_project.py b/add-to-project/test_add_to_project.py deleted file mode 100644 index 7dd74dd..0000000 --- a/add-to-project/test_add_to_project.py +++ /dev/null @@ -1,235 +0,0 @@ -# Copyright (c) 2026, NVIDIA CORPORATION. -# -# 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 base64 -import importlib.util -import pathlib -import unittest - - -MODULE_PATH = pathlib.Path(__file__).with_name("add_to_project.py") -SPEC = importlib.util.spec_from_file_location("add_to_project", MODULE_PATH) -add_to_project = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(add_to_project) - -AutomationError = add_to_project.AutomationError -run = add_to_project.run - -PROJECT_URL = "https://github.com/orgs/NVIDIA/projects/4" -DEFAULT_POM = """\ - - 4.0.0 - 26.08.0-SNAPSHOT - -""" - - -class FakeGitHubClient: - def __init__( - self, - item_exists=True, - roadmap=None, - latest=None, - pom=DEFAULT_POM, - has_next_page=False, - content_type="PullRequest", - item_exists_after_add_error=None, - ): - self.item_exists = item_exists - self.roadmap = roadmap - self.latest = latest - self.pom = pom - self.has_next_page = has_next_page - self.content_type = content_type - self.item_exists_after_add_error = item_exists_after_add_error - self.graphql_calls = [] - self.get_calls = [] - - @property - def operations(self): - return [operation for operation, _ in self.graphql_calls] - - def graphql(self, query, variables): - operation = next( - name - for name in ("Project", "AddItem", "SetRoadmap", "Roadmap") - if f"{name}(" in query - ) - self.graphql_calls.append((operation, variables)) - if operation == "Project": - item = { - "id": "ITEM", - "project": {"id": "PROJECT"}, - "fieldValueByName": self.roadmap, - } - return { - "organization": { - "projectV2": { - "id": "PROJECT", - "field": { - "__typename": "ProjectV2SingleSelectField", - "id": "ROADMAP_FIELD", - "options": [{"id": "OPTION_2608", "name": "26.08"}], - }, - } - }, - "content": { - "__typename": self.content_type, - "projectItems": { - "nodes": [item] if self.item_exists else [], - "pageInfo": {"hasNextPage": self.has_next_page}, - }, - }, - } - if operation == "AddItem": - if self.item_exists_after_add_error is not None: - self.item_exists = self.item_exists_after_add_error - raise AutomationError("add failed") - return { - "addProjectV2ItemById": { - "item": {"id": "ITEM", "fieldValueByName": None} - } - } - if operation == "Roadmap": - return {"item": {"fieldValueByName": self.latest}} - return {"updateProjectV2ItemFieldValue": {"projectV2Item": {"id": "ITEM"}}} - - def get(self, path, params=None): - self.get_calls.append((path, params)) - return { - "type": "file", - "encoding": "base64", - "content": base64.b64encode(self.pom.encode()).decode(), - } - - -def merged_event(repository): - return { - "pull_request": { - "node_id": "PR", - "number": 1, - "merged": True, - "base": {"ref": "branch-26.08"}, - "merge_commit_sha": "MERGE_SHA", - }, - "repository": {"full_name": repository}, - } - - -class AddToProjectTest(unittest.TestCase): - def test_merged_existing_item_sets_roadmap_in_both_repositories(self): - for repository in ("NVIDIA/cudf-spark", "NVIDIA/cudf-spark-jni"): - with self.subTest(repository=repository): - client = FakeGitHubClient() - run(client, merged_event(repository), PROJECT_URL, repository) - - self.assertEqual( - ["Project", "Roadmap", "SetRoadmap"], client.operations - ) - self.assertEqual( - [(f"repos/{repository}/contents/pom.xml", {"ref": "MERGE_SHA"})], - client.get_calls, - ) - set_variables = client.graphql_calls[-1][1] - self.assertEqual("OPTION_2608", set_variables["option"]) - - def test_merged_non_target_repository_does_not_set_roadmap(self): - repository = "NVIDIA/other" - client = FakeGitHubClient() - - run(client, merged_event(repository), PROJECT_URL, repository) - - self.assertEqual(["Project"], client.operations) - self.assertEqual([], client.get_calls) - - def test_existing_roadmap_is_preserved_without_reading_pom(self): - repository = "NVIDIA/cudf-spark" - client = FakeGitHubClient(roadmap={"name": "27.10"}) - - run(client, merged_event(repository), PROJECT_URL, repository) - - self.assertEqual(["Project"], client.operations) - self.assertEqual([], client.get_calls) - - def test_opened_content_is_added_without_setting_roadmap(self): - for content_type, key in (("Issue", "issue"), ("PullRequest", "pull_request")): - with self.subTest(content_type=content_type): - client = FakeGitHubClient( - item_exists=False, content_type=content_type - ) - event = {key: {"node_id": "CONTENT", "merged": False}} - - run(client, event, PROJECT_URL, "NVIDIA/cudf-spark") - - self.assertEqual(["Project", "AddItem"], client.operations) - self.assertEqual([], client.get_calls) - - def test_ambiguous_versions_never_update_roadmap(self): - invalid_poms = ( - "", - "26.08.026.10.0", - "${revision}", - ) - repository = "NVIDIA/cudf-spark" - for pom in invalid_poms: - with self.subTest(pom=pom): - client = FakeGitHubClient(pom=pom) - - with self.assertRaises(AutomationError): - run(client, merged_event(repository), PROJECT_URL, repository) - - self.assertNotIn("SetRoadmap", client.operations) - - def test_roadmap_set_while_running_is_preserved(self): - repository = "NVIDIA/cudf-spark" - client = FakeGitHubClient(latest={"name": "27.10"}) - - run(client, merged_event(repository), PROJECT_URL, repository) - - self.assertEqual(["Project", "Roadmap"], client.operations) - - def test_hidden_existing_item_fails_without_adding(self): - client = FakeGitHubClient(item_exists=False, has_next_page=True) - - with self.assertRaisesRegex(AutomationError, "more than 100 projects"): - run(client, {"pull_request": {"node_id": "PR"}}, PROJECT_URL, "repo") - - self.assertEqual(["Project"], client.operations) - - def test_duplicate_add_race_recovers_existing_item(self): - repository = "NVIDIA/cudf-spark" - client = FakeGitHubClient( - item_exists=False, item_exists_after_add_error=True - ) - - run(client, merged_event(repository), PROJECT_URL, repository) - - self.assertEqual( - ["Project", "AddItem", "Project", "Roadmap", "SetRoadmap"], - client.operations, - ) - - def test_add_failure_is_preserved_when_item_is_still_missing(self): - client = FakeGitHubClient( - item_exists=False, item_exists_after_add_error=False - ) - - with self.assertRaisesRegex(AutomationError, "add failed"): - run(client, {"issue": {"node_id": "ISSUE"}}, PROJECT_URL, "repo") - - self.assertEqual(["Project", "AddItem", "Project"], client.operations) - - -if __name__ == "__main__": - unittest.main()