Merge pull request #1 from mempool/knorrium/shared_gha
Add shared actions and workflows
This commit is contained in:
commit
0524095ab1
37
actions/compute-asset-hash/action.yml
Normal file
37
actions/compute-asset-hash/action.yml
Normal file
@ -0,0 +1,37 @@
|
||||
name: 'Compute Asset Cache Hash'
|
||||
description: 'Compute hash from mining-pool-logos and mempool-promo HEAD SHAs for cache keys'
|
||||
|
||||
inputs:
|
||||
github-token:
|
||||
description: 'GitHub token for API access'
|
||||
required: true
|
||||
cache-version:
|
||||
description: 'Cache version fallback when remote SHA lookup fails'
|
||||
required: false
|
||||
default: 'v1'
|
||||
|
||||
outputs:
|
||||
hash:
|
||||
description: 'Hash used for cache keys (12-char sha256 of both SHAs or cache-version)'
|
||||
value: ${{ steps.compute-hash.outputs.hash }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Compute asset cache key from remote SHAs
|
||||
id: compute-hash
|
||||
shell: bash
|
||||
env:
|
||||
FALLBACK_VERSION: ${{ inputs.cache-version }}
|
||||
run: |
|
||||
AUTH_HEADER="Authorization: Bearer ${{ inputs.github-token }}"
|
||||
LOGOS_SHA=$(curl -sf -H "$AUTH_HEADER" -H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"https://api.github.com/repos/mempool/mining-pool-logos/commits/HEAD" 2>/dev/null | jq -r '.sha // empty') || true
|
||||
PROMO_SHA=$(curl -sf -H "$AUTH_HEADER" -H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"https://api.github.com/repos/mempool/mempool-promo/commits/HEAD" 2>/dev/null | jq -r '.sha // empty') || true
|
||||
if [[ -n "$LOGOS_SHA" && -n "$PROMO_SHA" ]]; then
|
||||
HASH=$(echo -n "${LOGOS_SHA}${PROMO_SHA}" | sha256sum | cut -c1-12)
|
||||
else
|
||||
HASH="$FALLBACK_VERSION"
|
||||
fi
|
||||
echo "hash=$HASH" >> $GITHUB_OUTPUT
|
||||
77
actions/lint-test-build/action.yml
Normal file
77
actions/lint-test-build/action.yml
Normal file
@ -0,0 +1,77 @@
|
||||
name: 'Lint, Test, and Build'
|
||||
description: 'Run lint, test, and build steps for Node.js projects. Script inputs must be npm script names only and should only be set by trusted workflow authors.'
|
||||
|
||||
inputs:
|
||||
working-directory:
|
||||
description: 'Working directory for commands'
|
||||
required: true
|
||||
flavor:
|
||||
description: 'Build flavor (dev or prod) - lint and test only run for dev'
|
||||
required: false
|
||||
default: 'dev'
|
||||
run-lint:
|
||||
description: 'Whether to run lint step'
|
||||
required: false
|
||||
default: 'true'
|
||||
lint-script:
|
||||
description: 'npm script name for linting (e.g. from package.json). Must be a single script name; set only from trusted workflow authors.'
|
||||
required: false
|
||||
default: 'lint'
|
||||
run-test:
|
||||
description: 'Whether to run test step'
|
||||
required: false
|
||||
default: 'true'
|
||||
test-script:
|
||||
description: 'npm script name for testing (e.g. from package.json). Must be a single script name; set only from trusted workflow authors.'
|
||||
required: false
|
||||
default: 'test'
|
||||
run-build:
|
||||
description: 'Whether to run build step'
|
||||
required: false
|
||||
default: 'true'
|
||||
build-script:
|
||||
description: 'npm script name for building (e.g. from package.json). Must be a single script name; set only from trusted workflow authors.'
|
||||
required: false
|
||||
default: 'build'
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Lint
|
||||
if: inputs.run-lint == 'true' && inputs.flavor == 'dev'
|
||||
shell: bash
|
||||
env:
|
||||
LINT_SCRIPT: ${{ inputs.lint-script }}
|
||||
run: |
|
||||
if [[ ! "$LINT_SCRIPT" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
echo "::error::lint-script must match ^[a-zA-Z0-9_-]+$ (got: $LINT_SCRIPT)"
|
||||
exit 1
|
||||
fi
|
||||
npm run "$LINT_SCRIPT"
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: Test
|
||||
if: inputs.run-test == 'true' && inputs.flavor == 'dev'
|
||||
shell: bash
|
||||
env:
|
||||
TEST_SCRIPT: ${{ inputs.test-script }}
|
||||
run: |
|
||||
if [[ ! "$TEST_SCRIPT" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
echo "::error::test-script must match ^[a-zA-Z0-9_-]+$ (got: $TEST_SCRIPT)"
|
||||
exit 1
|
||||
fi
|
||||
npm run "$TEST_SCRIPT"
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: Build
|
||||
if: inputs.run-build == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
BUILD_SCRIPT: ${{ inputs.build-script }}
|
||||
run: |
|
||||
if [[ ! "$BUILD_SCRIPT" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
echo "::error::build-script must match ^[a-zA-Z0-9_-]+$ (got: $BUILD_SCRIPT)"
|
||||
exit 1
|
||||
fi
|
||||
npm run "$BUILD_SCRIPT"
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
70
actions/node-ci/action.yml
Normal file
70
actions/node-ci/action.yml
Normal file
@ -0,0 +1,70 @@
|
||||
name: 'Node.js CI Setup'
|
||||
description: 'Checkout repository, setup Node.js, cache node_modules, and install dependencies'
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: 'Node.js version to use'
|
||||
required: false
|
||||
default: '24.13.0'
|
||||
checkout-path:
|
||||
description: 'Path to checkout the repository'
|
||||
required: false
|
||||
default: '.'
|
||||
working-directory:
|
||||
description: 'Working directory for npm install (relative to checkout-path)'
|
||||
required: true
|
||||
flavor:
|
||||
description: 'Build flavor (dev or prod)'
|
||||
required: false
|
||||
default: 'dev'
|
||||
cache-prefix:
|
||||
description: 'Prefix for cache key'
|
||||
required: false
|
||||
default: 'node'
|
||||
checkout-submodules:
|
||||
description: 'Whether to checkout submodules'
|
||||
required: false
|
||||
default: 'false'
|
||||
checkout-ref:
|
||||
description: 'Git ref to checkout (branch, tag, or SHA)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: ${{ inputs.checkout-path }}
|
||||
# When checkout-ref is empty, actions/checkout uses the default github.ref
|
||||
ref: ${{ inputs.checkout-ref }}
|
||||
submodules: ${{ inputs.checkout-submodules }}
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '${{ inputs.checkout-path }}/${{ inputs.working-directory }}/package-lock.json'
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ inputs.checkout-path }}/${{ inputs.working-directory }}/node_modules
|
||||
key: ${{ runner.os }}-${{ inputs.cache-prefix }}-${{ inputs.flavor }}-node-${{ inputs.node-version }}-${{ hashFiles(format('{0}/{1}/package-lock.json', inputs.checkout-path, inputs.working-directory)) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ inputs.cache-prefix }}-${{ inputs.flavor }}-node-${{ inputs.node-version }}-
|
||||
${{ runner.os }}-${{ inputs.cache-prefix }}-${{ inputs.flavor }}-
|
||||
|
||||
- name: Install dependencies (dev)
|
||||
if: inputs.flavor == 'dev'
|
||||
shell: bash
|
||||
run: npm ci
|
||||
working-directory: ${{ inputs.checkout-path }}/${{ inputs.working-directory }}
|
||||
|
||||
- name: Install dependencies (prod)
|
||||
if: inputs.flavor == 'prod'
|
||||
shell: bash
|
||||
run: npm ci --omit=dev --omit=optional
|
||||
working-directory: ${{ inputs.checkout-path }}/${{ inputs.working-directory }}
|
||||
92
actions/restore-assets/action.yml
Normal file
92
actions/restore-assets/action.yml
Normal file
@ -0,0 +1,92 @@
|
||||
name: 'Restore Cached Assets'
|
||||
description: 'Restore cached mining pool and promo video assets from cache and/or artifacts'
|
||||
|
||||
inputs:
|
||||
frontend-path:
|
||||
description: 'Path to frontend directory'
|
||||
required: true
|
||||
github-token:
|
||||
description: 'GitHub token for API access (used to compute cache key from remote SHAs)'
|
||||
required: true
|
||||
use-artifacts:
|
||||
description: 'Whether to download from artifacts (requires cache job to have run first)'
|
||||
required: false
|
||||
default: 'true'
|
||||
cache-version:
|
||||
description: 'Cache version fallback when remote SHA lookup fails'
|
||||
required: false
|
||||
default: 'v1'
|
||||
|
||||
outputs:
|
||||
mining-pool-cache-hit:
|
||||
description: 'Whether mining pool assets were found in cache'
|
||||
value: ${{ steps.cache-mining-pool-restore.outputs.cache-hit }}
|
||||
promo-video-cache-hit:
|
||||
description: 'Whether promo video assets were found in cache'
|
||||
value: ${{ steps.cache-promo-video-restore.outputs.cache-hit }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Compute asset cache key from remote SHAs
|
||||
id: asset-hash
|
||||
uses: ./.github/actions/compute-asset-hash
|
||||
with:
|
||||
github-token: ${{ inputs.github-token }}
|
||||
cache-version: ${{ inputs.cache-version }}
|
||||
|
||||
- name: Restore cached mining pool assets
|
||||
continue-on-error: true
|
||||
id: cache-mining-pool-restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: mining-pool-assets.zip
|
||||
key: mining-pool-assets-${{ steps.asset-hash.outputs.hash }}
|
||||
restore-keys: |
|
||||
mining-pool-assets-
|
||||
|
||||
- name: Restore cached promo video assets
|
||||
continue-on-error: true
|
||||
id: cache-promo-video-restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: promo-video-assets.zip
|
||||
key: promo-video-assets-${{ steps.asset-hash.outputs.hash }}
|
||||
restore-keys: |
|
||||
promo-video-assets-
|
||||
|
||||
- name: Download mining pool artifact
|
||||
continue-on-error: true
|
||||
if: fromJSON(inputs.use-artifacts)
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: mining-pool-assets
|
||||
|
||||
- name: Download promo video artifact
|
||||
continue-on-error: true
|
||||
if: fromJSON(inputs.use-artifacts)
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: promo-video-assets
|
||||
|
||||
- name: Unzip mining pool assets
|
||||
shell: bash
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if [ -f "mining-pool-assets.zip" ]; then
|
||||
echo "Found mining-pool-assets.zip, unzipping..."
|
||||
unzip -o mining-pool-assets.zip -d "${{ inputs.frontend-path }}/src/resources/mining-pools"
|
||||
else
|
||||
echo "mining-pool-assets.zip not found; skipping unzip."
|
||||
fi
|
||||
|
||||
- name: Unzip promo video assets
|
||||
shell: bash
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if [ -f "promo-video-assets.zip" ]; then
|
||||
echo "Found promo-video-assets.zip, unzipping..."
|
||||
unzip -o promo-video-assets.zip -d "${{ inputs.frontend-path }}/src/resources/promo-video"
|
||||
else
|
||||
echo "promo-video-assets.zip not found; skipping unzip."
|
||||
fi
|
||||
58
actions/setup-rust/action.yml
Normal file
58
actions/setup-rust/action.yml
Normal file
@ -0,0 +1,58 @@
|
||||
name: 'Setup Rust Toolchain'
|
||||
description: 'Read rust-toolchain file, cache Rust dependencies, and install the toolchain'
|
||||
|
||||
inputs:
|
||||
working-directory:
|
||||
description: 'Working directory (repository root)'
|
||||
required: true
|
||||
rust-toolchain-path:
|
||||
description: 'Path to rust-toolchain file relative to working-directory'
|
||||
required: false
|
||||
default: 'rust/gbt/rust-toolchain'
|
||||
cargo-lock-path:
|
||||
description: 'Path pattern for Cargo.lock files relative to working-directory'
|
||||
required: false
|
||||
default: 'rust/gbt/**/Cargo.lock'
|
||||
rust-target-path:
|
||||
description: 'Path to Rust target directory relative to working-directory'
|
||||
required: false
|
||||
default: 'rust/gbt/target/'
|
||||
flavor:
|
||||
description: 'Build flavor for cache key differentiation'
|
||||
required: false
|
||||
default: 'dev'
|
||||
|
||||
outputs:
|
||||
toolchain:
|
||||
description: 'The Rust toolchain version that was installed'
|
||||
value: ${{ steps.gettoolchain.outputs.toolchain }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Read rust-toolchain file
|
||||
id: gettoolchain
|
||||
shell: bash
|
||||
run: echo "toolchain=$(cat ./${{ inputs.rust-toolchain-path }})" >> $GITHUB_OUTPUT
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
${{ inputs.working-directory }}/${{ inputs.rust-target-path }}
|
||||
key: ${{ runner.os }}-cargo-${{ inputs.flavor }}-${{ hashFiles(format('{0}/{1}', inputs.working-directory, inputs.cargo-lock-path)) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ inputs.flavor }}-
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Install Rust toolchain
|
||||
# dtolnay/rust-toolchain pinned to a specific commit SHA for supply chain security.
|
||||
# To identify or update the corresponding version/tag, check this SHA in the dtolnay/rust-toolchain repository.
|
||||
uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561
|
||||
with:
|
||||
toolchain: ${{ steps.gettoolchain.outputs.toolchain }}
|
||||
122
actions/sync-assets/action.yml
Normal file
122
actions/sync-assets/action.yml
Normal file
@ -0,0 +1,122 @@
|
||||
name: 'Sync and Cache Assets'
|
||||
description: 'Sync assets from CDN, zip, upload as artifacts, and save to cache. Script input must be an npm script name only and should only be set by trusted workflow authors.'
|
||||
|
||||
inputs:
|
||||
frontend-path:
|
||||
description: 'Path to frontend directory'
|
||||
required: true
|
||||
github-token:
|
||||
description: 'GitHub token for API access'
|
||||
required: true
|
||||
sync-script:
|
||||
description: 'npm script name for syncing assets (e.g. from package.json). Must be a single script name; set only from trusted workflow authors.'
|
||||
required: false
|
||||
default: 'sync-assets-dev'
|
||||
cache-version:
|
||||
description: 'Cache version fallback when remote SHA lookup fails'
|
||||
required: false
|
||||
default: 'v1'
|
||||
|
||||
outputs:
|
||||
cache-key-hash:
|
||||
description: 'Hash used for cache keys (from remote SHAs or cache-version fallback)'
|
||||
value: ${{ steps.asset-hash.outputs.hash }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Compute asset cache key from remote SHAs
|
||||
id: asset-hash
|
||||
uses: ./.github/actions/compute-asset-hash
|
||||
with:
|
||||
github-token: ${{ inputs.github-token }}
|
||||
cache-version: ${{ inputs.cache-version }}
|
||||
|
||||
# Cache miss is success with cache-hit=false; only real errors (network, permissions) fail the step.
|
||||
- name: Restore cached mining pool assets
|
||||
id: cache-mining-pool-restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: mining-pool-assets.zip
|
||||
key: mining-pool-assets-${{ steps.asset-hash.outputs.hash }}
|
||||
restore-keys: |
|
||||
mining-pool-assets-
|
||||
|
||||
# Cache miss is success with cache-hit=false; only real errors (network, permissions) fail the step.
|
||||
- name: Restore cached promo video assets
|
||||
id: cache-promo-video-restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: promo-video-assets.zip
|
||||
key: promo-video-assets-${{ steps.asset-hash.outputs.hash }}
|
||||
restore-keys: |
|
||||
promo-video-assets-
|
||||
|
||||
- name: Unzip mining pool assets before sync
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -f "mining-pool-assets.zip" ]]; then
|
||||
unzip -o mining-pool-assets.zip -d "${{ inputs.frontend-path }}/src/resources/mining-pools"
|
||||
else
|
||||
echo "No cached mining pool assets zip found; skipping unzip."
|
||||
fi
|
||||
|
||||
- name: Unzip promo video assets before sync
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -f "promo-video-assets.zip" ]]; then
|
||||
unzip -o promo-video-assets.zip -d "${{ inputs.frontend-path }}/src/resources/promo-video"
|
||||
else
|
||||
echo "No cached promo video assets zip found; skipping unzip."
|
||||
fi
|
||||
|
||||
- name: Sync assets
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.github-token }}
|
||||
MEMPOOL_CDN: 1
|
||||
VERBOSE: 1
|
||||
SYNC_SCRIPT: ${{ inputs.sync-script }}
|
||||
run: |
|
||||
if [[ ! "$SYNC_SCRIPT" =~ ^[a-zA-Z0-9_-]+$ ]]; then
|
||||
echo "::error::sync-script must match ^[a-zA-Z0-9_-]+$ (got: $SYNC_SCRIPT)"
|
||||
exit 1
|
||||
fi
|
||||
npm run "$SYNC_SCRIPT"
|
||||
working-directory: ${{ inputs.frontend-path }}
|
||||
|
||||
- name: Zip mining pool assets
|
||||
shell: bash
|
||||
run: zip -jrq mining-pool-assets.zip ${{ inputs.frontend-path }}/src/resources/mining-pools/*
|
||||
|
||||
- name: Zip promo video assets
|
||||
shell: bash
|
||||
run: zip -jrq promo-video-assets.zip ${{ inputs.frontend-path }}/src/resources/promo-video/*
|
||||
|
||||
- name: Upload mining pool assets artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mining-pool-assets
|
||||
path: mining-pool-assets.zip
|
||||
|
||||
- name: Upload promo video assets artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: promo-video-assets
|
||||
path: promo-video-assets.zip
|
||||
|
||||
- name: Save mining pool assets cache
|
||||
if: steps.cache-mining-pool-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: mining-pool-assets.zip
|
||||
key: mining-pool-assets-${{ steps.asset-hash.outputs.hash }}
|
||||
|
||||
- name: Save promo video assets cache
|
||||
if: steps.cache-promo-video-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: promo-video-assets.zip
|
||||
key: promo-video-assets-${{ steps.asset-hash.outputs.hash }}
|
||||
201
workflows/project-board-automation.yml
Normal file
201
workflows/project-board-automation.yml
Normal file
@ -0,0 +1,201 @@
|
||||
# Reusable workflow: Automate project board management
|
||||
# - Add newly created issues to project board
|
||||
# - Set status to "Review Needed" when a reviewer is requested on a non-draft PR
|
||||
name: Project Board Automation
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
project-number:
|
||||
description: 'The project board number'
|
||||
required: false
|
||||
type: number
|
||||
default: 8
|
||||
runs-on:
|
||||
description: 'Runner to use for the job'
|
||||
required: false
|
||||
type: string
|
||||
default: 'ubuntu-latest'
|
||||
handle-issues:
|
||||
description: 'Whether to handle adding issues to project board'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
secrets:
|
||||
PROJECT_TOKEN:
|
||||
description: 'PAT with project write access'
|
||||
required: true
|
||||
PROJECT_ID:
|
||||
description: 'The project unique identifier'
|
||||
required: true
|
||||
STATUS_FIELD_ID:
|
||||
description: 'The Status field unique identifier'
|
||||
required: true
|
||||
REVIEW_NEEDED_OPTION_ID:
|
||||
description: 'The Review Needed option unique identifier'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
manage-project-board:
|
||||
runs-on: ${{ inputs.runs-on }}
|
||||
steps:
|
||||
- name: Update Project Board
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.PROJECT_TOKEN }}
|
||||
script: |
|
||||
const projectNumber = ${{ inputs.project-number }};
|
||||
const handleIssues = ${{ inputs.handle-issues }};
|
||||
|
||||
// Skip draft PRs
|
||||
if (
|
||||
context.eventName === 'pull_request' &&
|
||||
context.payload &&
|
||||
context.payload.pull_request &&
|
||||
context.payload.pull_request.draft
|
||||
) {
|
||||
console.log('PR is a draft, skipping Review Needed status...');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle new issues - add to project
|
||||
if (context.eventName === 'issues' && handleIssues) {
|
||||
const addMutation = `
|
||||
mutation($projectId: ID!, $contentId: ID!) {
|
||||
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
|
||||
item { id }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
await github.graphql(addMutation, {
|
||||
projectId: "${{ secrets.PROJECT_ID }}",
|
||||
contentId: context.payload.issue.node_id
|
||||
});
|
||||
|
||||
console.log(`Successfully added issue to project #${projectNumber}`);
|
||||
} catch (error) {
|
||||
const errors = error?.errors ?? [];
|
||||
const isAlreadyInProject = errors.some(e => {
|
||||
if (typeof e.message !== 'string') return false;
|
||||
// Only handle errors from our mutation
|
||||
const path = e.path ?? [];
|
||||
const fromOurMutation = Array.isArray(path) && path.includes('addProjectV2ItemById');
|
||||
if (!fromOurMutation) return false;
|
||||
const isUnprocessable = e.extensions?.code === 'UNPROCESSABLE';
|
||||
if (!isUnprocessable) return false;
|
||||
// Match the known GitHub error phrasing for duplicate items
|
||||
const msg = e.message.toLowerCase();
|
||||
return /already\s+(exists\s+)?in\s+(this\s+)?project\b/i.test(msg);
|
||||
});
|
||||
|
||||
if (isAlreadyInProject) {
|
||||
console.log(`Issue is already in project #${projectNumber}, skipping add.`);
|
||||
} else {
|
||||
console.error(`Failed to add issue to project #${projectNumber}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle PR review_requested - update status to "Review Needed"
|
||||
if (context.eventName === 'pull_request') {
|
||||
// GraphQL query to find the PR's project items
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $pr) {
|
||||
projectItems(first: 50, after: $cursor) {
|
||||
nodes {
|
||||
id
|
||||
project {
|
||||
number
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Paginate through all project items for the PR
|
||||
const MAX_PAGES = 100;
|
||||
let projectItems = [];
|
||||
let cursor = null;
|
||||
let hasNextPage = true;
|
||||
let page = 0;
|
||||
|
||||
while (hasNextPage && page < MAX_PAGES) {
|
||||
page += 1;
|
||||
const result = await github.graphql(query, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pr: context.payload.pull_request.number,
|
||||
cursor
|
||||
});
|
||||
|
||||
const projectItemsConnection = result.repository.pullRequest.projectItems;
|
||||
projectItems = projectItems.concat(projectItemsConnection.nodes || []);
|
||||
hasNextPage = projectItemsConnection.pageInfo.hasNextPage;
|
||||
cursor = projectItemsConnection.pageInfo.endCursor;
|
||||
}
|
||||
|
||||
if (hasNextPage) {
|
||||
console.warn(`Stopped after ${MAX_PAGES} pages; more project items may exist.`);
|
||||
const message = `Stopped after ${MAX_PAGES} pages; more project items may exist. Failing to avoid using incomplete project data.`;
|
||||
console.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// Find the project item that belongs to the target project
|
||||
const projectItem = projectItems.find(item => item.project.number === projectNumber);
|
||||
|
||||
// Exit early if PR isn't in the target project
|
||||
if (!projectItem) {
|
||||
console.log(`PR is not in project #${projectNumber}, skipping...`);
|
||||
return;
|
||||
}
|
||||
|
||||
// GraphQL mutation to update the Status field
|
||||
const mutation = `
|
||||
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
||||
updateProjectV2ItemFieldValue(
|
||||
input: {
|
||||
projectId: $projectId
|
||||
itemId: $itemId
|
||||
fieldId: $fieldId
|
||||
value: { singleSelectOptionId: $optionId }
|
||||
}
|
||||
) {
|
||||
projectV2Item {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Execute the mutation with error handling for better debugging
|
||||
try {
|
||||
await github.graphql(mutation, {
|
||||
projectId: "${{ secrets.PROJECT_ID }}",
|
||||
itemId: projectItem.id,
|
||||
fieldId: "${{ secrets.STATUS_FIELD_ID }}",
|
||||
optionId: "${{ secrets.REVIEW_NEEDED_OPTION_ID }}"
|
||||
});
|
||||
|
||||
console.log('Successfully updated project status to Review Needed');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to update project status to Review Needed for project #${projectNumber} and item ${projectItem.id}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user