From 9ccb1c5c8c07898b9bf1a468a2b19c57a826b60c Mon Sep 17 00:00:00 2001 From: Andrei Vorobev <738482+vorobey@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:56:01 +0300 Subject: [PATCH] chore: automated link pr and issues draft --- .github/workflows/link-pr-to-project.yml | 95 ++++++++++++++++++++++++ .github/workflows/pr-project-link.yml | 15 ++++ 2 files changed, 110 insertions(+) create mode 100644 .github/workflows/link-pr-to-project.yml create mode 100644 .github/workflows/pr-project-link.yml diff --git a/.github/workflows/link-pr-to-project.yml b/.github/workflows/link-pr-to-project.yml new file mode 100644 index 000000000000..a08155b411ab --- /dev/null +++ b/.github/workflows/link-pr-to-project.yml @@ -0,0 +1,95 @@ +name: Link PR to Project + +on: + workflow_call: + inputs: + project-owner: + description: 'Organization (or user) login that owns the target project' + required: true + type: string + project-number: + description: 'Number of the target GitHub Projects v2 board (e.g. devextreme-private)' + required: true + type: number + secrets: + project-token: + description: 'PAT/App token with org Projects v2 read+write access' + required: true + +jobs: + link: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Link PR to project card + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.project-token }} + script: | + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + + const titleIdRe = /^[\w.-]+\/(\d+)_/; + const titleMatch = pr.title.match(titleIdRe); + if (!titleMatch) { + core.notice( + `PR title "${pr.title}" doesn't match the "type/_description" convention - skipping project link.`, + ); + return; + } + const issueNumber = parseInt(titleMatch[1], 10); + + const issueResult = await github.graphql( + `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { id } + } + }`, + { owner, repo, number: issueNumber }, + ); + const issueNode = issueResult.repository && issueResult.repository.issue; + if (!issueNode) { + core.warning(`Issue #${issueNumber} not found in ${owner}/${repo} - skipping.`); + return; + } + + const projectOwner = '${{ inputs.project-owner }}'; + const projectNumber = ${{ inputs.project-number }}; + + const itemsResult = await github.graphql( + `query($id: ID!) { + node(id: $id) { + ... on Issue { + projectItems(first: 20) { + nodes { + project { id number owner { login } } + } + } + } + } + }`, + { id: issueNode.id }, + ); + const projectItems = itemsResult.node.projectItems.nodes; + const projectItem = projectItems.find( + (i) => i.project.number === projectNumber && i.project.owner.login === projectOwner, + ); + if (!projectItem) { + core.warning( + `Issue #${issueNumber} is not on project ${projectOwner}#${projectNumber} - nothing to link.`, + ); + return; + } + + await github.graphql( + `mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { + item { id } + } + }`, + { projectId: projectItem.project.id, contentId: pr.node_id }, + ); + core.notice( + `Linked PR #${pr.number} to the card for issue #${issueNumber} on ${projectOwner}#${projectNumber}.`, + ); diff --git a/.github/workflows/pr-project-link.yml b/.github/workflows/pr-project-link.yml new file mode 100644 index 000000000000..772e40cfc078 --- /dev/null +++ b/.github/workflows/pr-project-link.yml @@ -0,0 +1,15 @@ +name: PR Project Link + +on: + pull_request_target: + types: [opened, edited, reopened, synchronize] + +jobs: + link-to-devextreme-private: + uses: ./.github/workflows/link-pr-to-project.yml + with: + project-owner: DevExpress + # TODO: set to the actual devextreme-private project number + project-number: 0 + secrets: + project-token: ${{ secrets.DX_ROBOT_PAT }}