Skip to content

Closes #796 - Add Ontology item type support #1019

Closes #796 - Add Ontology item type support

Closes #796 - Add Ontology item type support #1019

name: Validate PR
description: "Validate pull requests for code conventions, naming conventions, linked issues, and version bumps"
on:
pull_request:
branches: ["main"]
types: [opened, edited, synchronize, ready_for_review, labeled, unlabeled]
permissions:
contents: read
pull-requests: write
issues: write
statuses: write
jobs:
format_ruff:
if: ${{ github.event.action != 'labeled' && github.event.action != 'unlabeled' }}
name: Code Formatted
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: "3.x"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff
- name: Run ruff format
run: |
if ruff format; then
echo "✅ ruff format passed."
else
echo "❌ ruff format failed."
exit 1
fi
lint_ruff:
if: ${{ github.event.action != 'labeled' && github.event.action != 'unlabeled' }}
name: Code Linted
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: "3.x"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff
- name: Run ruff lint
run: |
if ruff check; then
echo "✅ ruff lint passed."
else
echo "❌ ruff lint failed."
exit 1
fi
validate-version-bump:
name: Proper Version Bump
runs-on: ubuntu-latest
outputs:
is_version_bump: ${{ steps.version_bump_check.outputs.is_version_bump }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate Version Bump
id: version_bump_check
run: |
set -e
PR_TITLE="${{ github.event.pull_request.title }}"
VERSION_REGEX='^v([0-9]+\.[0-9]+\.[0-9]+)$'
BASE_REF="origin/main"
CHANGED_FILES=$(git diff --name-only $BASE_REF...${{ github.event.pull_request.head.sha }})
VERSION_CHANGED=false
TITLE_IS_VERSION=false
OLD_VERSION=""
NEW_VERSION=""
# Get VERSION from main branch
OLD_VERSION=$(git show origin/main:src/fabric_cicd/constants.py | grep '^VERSION = ' | sed -E 's/VERSION = "([^"]+)"/\1/')
# Get VERSION from current branch
NEW_VERSION=$(grep '^VERSION = ' src/fabric_cicd/constants.py | sed -E 's/VERSION = "([^"]+)"/\1/')
if [ "$OLD_VERSION" != "$NEW_VERSION" ]; then
VERSION_CHANGED=true
fi
if [[ "$PR_TITLE" =~ $VERSION_REGEX ]]; then
TITLE_IS_VERSION=true
fi
if [ "$TITLE_IS_VERSION" = true ] || [ "$VERSION_CHANGED" = true ]; then
echo "is_version_bump=true" >> $GITHUB_OUTPUT
else
echo "is_version_bump=false" >> $GITHUB_OUTPUT
echo "✅ Version bump validation passed."
exit 0
fi
# 1. If version is updated, title must match
if [ "$VERSION_CHANGED" = true ]; then
EXPECTED_TITLE="v$NEW_VERSION"
if [ "$PR_TITLE" != "$EXPECTED_TITLE" ]; then
echo "❌ PR title must be vX.X.X when VERSION is changed. Expected title: $EXPECTED_TITLE"
exit 1
fi
fi
# 2. If title is version format, constants.py, changelog.md, and .changes/v<version>.md must be included in changed files
# Note: Additional files (e.g., removed unreleased change files) are allowed alongside the required files
if [[ "$PR_TITLE" =~ $VERSION_REGEX ]]; then
VERSION_NUMBER="${BASH_REMATCH[1]}"
REQUIRED_FILES=("src/fabric_cicd/constants.py" "docs/changelog.md" ".changes/v${VERSION_NUMBER}.md")
MISSING_FILES=()
for file in "${REQUIRED_FILES[@]}"; do
if ! echo "$CHANGED_FILES" | grep -Fqx "$file"; then
MISSING_FILES+=("$file")
fi
done
if [ ${#MISSING_FILES[@]} -ne 0 ]; then
echo "❌ The following required files must be included in a PR titled vX.X.X: ${MISSING_FILES[*]}"
exit 1
fi
fi
echo "✅ Version bump validation passed."
check-author-permissions:
name: Check Author Permissions
runs-on: ubuntu-latest
outputs:
skip_validation: ${{ steps.check_permissions.outputs.skip_validation }}
permissions:
pull-requests: read
steps:
- name: Check PR Author Collaborator Role
id: check_permissions
uses: actions/github-script@v7
with:
script: |
const prAuthor = context.payload.pull_request.user.login;
const repo = context.repo;
// Skip validation for dependabot
if (prAuthor === 'dependabot[bot]') {
console.log('✅ Dependabot PR detected. Skipping linked issue validation.');
core.setOutput('skip_validation', 'true');
return;
}
console.log(`Checking collaborator role for PR author: ${prAuthor}`);
try {
const { data: collaborator } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: repo.owner,
repo: repo.repo,
username: prAuthor
});
const roleName = collaborator.role_name;
console.log(`Collaborator role for ${prAuthor}: ${roleName}`);
// Skip validation for users with admin, maintain, or write role
const skipValidation = ['admin', 'maintain', 'write'].includes(roleName);
core.setOutput('skip_validation', skipValidation.toString());
if (skipValidation) {
console.log(`✅ User ${prAuthor} has ${roleName} role. Validation job for linked issue will be skipped.`);
} else {
console.log(`ℹ️ User ${prAuthor} has ${roleName} role. Validation job for linked issue will run.`);
}
} catch (error) {
console.log(`⚠️ Could not determine collaborator role for ${prAuthor}: ${error.message}`);
console.log('Defaulting to running validation job for linked issue.');
core.setOutput('skip_validation', 'false');
}
validate-linked-issue:
name: Issue Linked
runs-on: ubuntu-latest
needs: [check-author-permissions, validate-version-bump]
if: needs.check-author-permissions.outputs.skip_validation == 'false' && needs.validate-version-bump.outputs.is_version_bump == 'false' && !contains(github.event.pull_request.labels.*.name, 'skip changelog')
permissions:
pull-requests: read
issues: read
steps:
- name: Validate Linked Issue
uses: actions/github-script@v7
with:
script: |
const prNumber = context.issue.number;
const repo = context.repo;
// Get PR details
const pr = await github.rest.pulls.get({
owner: repo.owner,
repo: repo.repo,
pull_number: prNumber
});
const prTitle = pr.data.title || '';
// First, check for issues linked to the PR via GitHub's native linking
let linkedIssues = [];
try {
// Use GraphQL to get linked issues
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 10) {
nodes {
number
}
}
}
}
}
`;
const result = await github.graphql(query, {
owner: repo.owner,
repo: repo.repo,
number: prNumber
});
linkedIssues = result.repository.pullRequest.closingIssuesReferences.nodes;
if (linkedIssues.length > 0) {
console.log(`✅ Found ${linkedIssues.length} linked issue(s): ${linkedIssues.map(issue => `#${issue.number}`).join(', ')}`);
console.log('✅ Pull request is properly linked to an issue via GitHub linking.');
return;
}
} catch (error) {
console.log('⚠️ Could not check for linked issues via GraphQL, falling back to text analysis:', error.message);
}
// Issue reference patterns - more specific to avoid false positives
const keywordPatterns = [
/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi,
/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)/gi
];
// Generic hash pattern (less specific, used as fallback)
const hashPattern = /#(\d+)(?!\w)/g;
let foundIssueNumbers = new Set();
// Check PR title for issue references
const textToCheck = prTitle;
// First, look for keyword-based references (more reliable)
for (const pattern of keywordPatterns) {
const matches = textToCheck.matchAll(pattern);
for (const match of matches) {
const issueNumber = match[1];
if (issueNumber && !isNaN(issueNumber)) {
foundIssueNumbers.add(parseInt(issueNumber));
}
}
}
// If no keyword-based references found, look for simple hash references
if (foundIssueNumbers.size === 0) {
const matches = textToCheck.matchAll(hashPattern);
for (const match of matches) {
const issueNumber = match[1];
if (issueNumber && !isNaN(issueNumber)) {
foundIssueNumbers.add(parseInt(issueNumber));
}
}
}
if (foundIssueNumbers.size === 0) {
core.setFailed(
'❌ This pull request must be linked to an issue. Please:\n' +
'1. Reference an issue in the PR title using "Fixes #123", "Closes #456", or "Resolves #789"\n' +
'2. Make sure the referenced issue exists in this repository\n\n' +
'See our contribution guidelines for more details.'
);
return;
}
// Verify that the referenced issues actually exist
let validIssueFound = false;
const invalidIssues = [];
for (const issueNumber of foundIssueNumbers) {
try {
await github.rest.issues.get({
owner: repo.owner,
repo: repo.repo,
issue_number: issueNumber
});
validIssueFound = true;
console.log(`✅ Found valid issue reference: #${issueNumber}`);
} catch (error) {
if (error.status === 404) {
invalidIssues.push(issueNumber);
console.log(`❌ Issue #${issueNumber} does not exist`);
}
}
}
if (!validIssueFound) {
const invalidList = invalidIssues.length > 0 ?
`\n\nInvalid issue references found: ${invalidIssues.map(n => `#${n}`).join(', ')}` : '';
core.setFailed(
'❌ This pull request must be linked to a valid issue in this repository.' +
invalidList +
'\n\nPlease:\n' +
'1. Create an issue first if one doesn\'t exist\n' +
'2. Reference the issue in the PR title using "Fixes #123", "Closes #456", or "Resolves #789"\n' +
'3. Make sure the issue number is correct\n\n' +
'See our contribution guidelines for more details.'
);
return;
}
console.log('✅ Pull request is properly linked to an issue.');