chore: import upstream snapshot with attribution
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Has been cancelled
Java Checkstyle / java-checkstyle (push) Has been cancelled
Maven Collate Tests / maven-collate-ci (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Has been cancelled
Publish Package to Maven Central Repository / publish-maven-packages (push) Has been cancelled
OpenMetadata Service Unit Tests / Detect Changes (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Has been cancelled
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Has been cancelled
Java Checkstyle / java-checkstyle (push) Has been cancelled
Maven Collate Tests / maven-collate-ci (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Has been cancelled
Publish Package to Maven Central Repository / publish-maven-packages (push) Has been cancelled
OpenMetadata Service Unit Tests / Detect Changes (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Has been cancelled
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
# Copyright 2026 Collate
|
||||
# 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: PR Metadata Validation
|
||||
|
||||
# Fails the PR check unless it links a GitHub issue that (a) has a real
|
||||
# description and (b) is tracked in the "Shipping" project with the Status,
|
||||
# Source, Priority, Domain and Release fields set.
|
||||
#
|
||||
# Linked issues are resolved from two sources:
|
||||
# * Same-repo: GitHub's native `closingIssuesReferences` (the PR "Development"
|
||||
# section + same-repo closing keywords). This is authoritative and also
|
||||
# catches issues linked via the sidebar without a body keyword.
|
||||
# * Cross-repo (same org only): parsed from the PR body, because GitHub cannot
|
||||
# represent a cross-repo link in `closingIssuesReferences`. This path is
|
||||
# restricted to trusted authors and an explicit repo allowlist so the
|
||||
# privileged PROJECTS_TOKEN is never pointed at attacker-chosen repositories
|
||||
# under pull_request_target, and the public PR comment never echoes private
|
||||
# issue content (only numbers and field names).
|
||||
#
|
||||
# Projects v2 data is only available via GraphQL and the default GITHUB_TOKEN
|
||||
# cannot read org-level projects (nor private same-org repos), so a PROJECTS_TOKEN
|
||||
# secret (a PAT or GitHub App token with `read:project` and read access to the
|
||||
# allowlisted repos) is required. Only PR / issue / project metadata is read
|
||||
# through the API — no untrusted checkout — so pull_request_target is safe here.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened, synchronize, ready_for_review]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "PR number to validate"
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
# Serialize runs per PR (avoids duplicate sticky comments) but never cancel:
|
||||
# this check gates merges, so every event must reach a definitive conclusion.
|
||||
group: pr-metadata-validation-${{ github.event.pull_request.number || github.event.inputs.pr_number || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate-pr-metadata:
|
||||
name: Validate PR Metadata
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check linked issue and Shipping project fields
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
env:
|
||||
PROJECTS_TOKEN: ${{ secrets.PROJECTS_TOKEN }}
|
||||
with:
|
||||
script: |
|
||||
const CHECK_MARKER = '<!-- pr-metadata-check -->';
|
||||
const BYPASS_LABEL = 'skip-pr-checks';
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// ---- Policy --------------------------------------------------------
|
||||
const REQUIRE_LINKED_ISSUE = true;
|
||||
const MIN_DESCRIPTION_WORDS = 25;
|
||||
const PROJECT_NAME = 'Shipping';
|
||||
const REQUIRED_PROJECT_FIELDS = ['Status', 'Source', 'Priority', 'Domain', 'Release'];
|
||||
const FAIL_WHEN_PROJECT_UNVERIFIABLE = true;
|
||||
|
||||
// ---- Cross-repo policy (security-sensitive) ------------------------
|
||||
// closingIssuesReferences is same-repo only, so a cross-repo link can
|
||||
// only live in the PR body. Honor it ONLY for trusted authors and ONLY
|
||||
// for these same-org repos, so untrusted fork PRs can never drive the
|
||||
// privileged token at arbitrary repositories or probe private issues.
|
||||
const CLOSING_KEYWORDS = '(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)';
|
||||
const ALLOWED_CROSS_REPOS = new Set(['openmetadata-collate']);
|
||||
const TRUSTED_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
||||
|
||||
const PR_LINKS_QUERY = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
closingIssuesReferences(first: 25) {
|
||||
nodes { number repository { name owner { login } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const ISSUE_PROJECT_QUERY = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issue(number: $number) {
|
||||
projectItems(first: 25) {
|
||||
nodes {
|
||||
project { title }
|
||||
fieldValues(first: 50) {
|
||||
nodes {
|
||||
__typename
|
||||
... on ProjectV2ItemFieldSingleSelectValue { name field { ... on ProjectV2FieldCommon { name } } }
|
||||
... on ProjectV2ItemFieldTextValue { text field { ... on ProjectV2FieldCommon { name } } }
|
||||
... on ProjectV2ItemFieldNumberValue { number field { ... on ProjectV2FieldCommon { name } } }
|
||||
... on ProjectV2ItemFieldIterationValue { title field { ... on ProjectV2FieldCommon { name } } }
|
||||
... on ProjectV2ItemFieldDateValue { date field { ... on ProjectV2FieldCommon { name } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
function elevatedClient() {
|
||||
const token = process.env.PROJECTS_TOKEN;
|
||||
return token ? new github.constructor({ auth: token }) : null;
|
||||
}
|
||||
|
||||
const isSameRepo = (ref) => ref.owner === owner && ref.repo === repo;
|
||||
const refKey = (ref) => `${ref.owner}/${ref.repo}#${ref.number}`;
|
||||
const refLabel = (ref) => (isSameRepo(ref) ? `#${ref.number}` : refKey(ref));
|
||||
const dedupeRefs = (refs) => [...new Map(refs.map((r) => [refKey(r), r])).values()];
|
||||
|
||||
async function loadPullRequest() {
|
||||
if (context.payload.pull_request) {
|
||||
return context.payload.pull_request;
|
||||
}
|
||||
const pull_number = Number(context.payload.inputs.pr_number);
|
||||
const { data } = await github.rest.pulls.get({ owner, repo, pull_number });
|
||||
return data;
|
||||
}
|
||||
|
||||
// Same-repo links: authoritative, includes sidebar-linked issues.
|
||||
async function nativeLinkedRefs(prNumber) {
|
||||
const data = await github.graphql(PR_LINKS_QUERY, { owner, repo, number: prNumber });
|
||||
const nodes =
|
||||
(data.repository &&
|
||||
data.repository.pullRequest &&
|
||||
data.repository.pullRequest.closingIssuesReferences.nodes) ||
|
||||
[];
|
||||
return nodes.map((node) => ({
|
||||
owner: node.repository.owner.login,
|
||||
repo: node.repository.name,
|
||||
number: node.number,
|
||||
}));
|
||||
}
|
||||
|
||||
// Trust the author for cross-repo refs if their PR association is already a trusted
|
||||
// value, or — since the default GITHUB_TOKEN cannot see *private* org membership and
|
||||
// reports such authors as CONTRIBUTOR — by confirming org membership with the elevated
|
||||
// token. Fork PRs from non-members still resolve to untrusted.
|
||||
async function isTrustedAuthor(pr) {
|
||||
let trusted = TRUSTED_ASSOCIATIONS.has(pr.author_association);
|
||||
const client = elevatedClient();
|
||||
if (!trusted && client && pr.user && pr.user.login) {
|
||||
try {
|
||||
await client.rest.orgs.checkMembershipForUser({ org: owner, username: pr.user.login });
|
||||
trusted = true; // 204 => org member (including private membership)
|
||||
} catch (error) {
|
||||
trusted = false; // 404 => not a member
|
||||
}
|
||||
}
|
||||
return trusted;
|
||||
}
|
||||
|
||||
// Cross-repo links: body-parsed, allowlisted repos only (caller gates on author trust).
|
||||
function crossRepoRefsFromBody(text) {
|
||||
const refs = [];
|
||||
const add = (refOwner, refRepo, number) => {
|
||||
if (refOwner === owner && ALLOWED_CROSS_REPOS.has(refRepo)) {
|
||||
refs.push({ owner: refOwner, repo: refRepo, number: Number(number) });
|
||||
}
|
||||
};
|
||||
const shorthand = new RegExp(
|
||||
`\\b${CLOSING_KEYWORDS}\\b\\s*:?\\s*([\\w.-]+)/([\\w.-]+)#(\\d+)`,
|
||||
'gi'
|
||||
);
|
||||
const url = new RegExp(
|
||||
`\\b${CLOSING_KEYWORDS}\\b\\s*:?\\s*https?://github\\.com/([\\w.-]+)/([\\w.-]+)/issues/(\\d+)`,
|
||||
'gi'
|
||||
);
|
||||
let match;
|
||||
while ((match = shorthand.exec(text)) !== null) {
|
||||
add(match[1], match[2], match[3]);
|
||||
}
|
||||
while ((match = url.exec(text)) !== null) {
|
||||
add(match[1], match[2], match[3]);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function hasProperDescription(body) {
|
||||
if (!body) {
|
||||
return false;
|
||||
}
|
||||
const meaningful = body.replace(/<!--[\s\S]*?-->/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const wordCount = meaningful ? meaningful.split(' ').filter(Boolean).length : 0;
|
||||
return wordCount >= MIN_DESCRIPTION_WORDS;
|
||||
}
|
||||
|
||||
async function loadIssue(ref) {
|
||||
const client = isSameRepo(ref) ? github : elevatedClient();
|
||||
if (!client) {
|
||||
return { error: 'missing-token' };
|
||||
}
|
||||
try {
|
||||
const { data } = await client.rest.issues.get({
|
||||
owner: ref.owner,
|
||||
repo: ref.repo,
|
||||
issue_number: ref.number,
|
||||
});
|
||||
return { issue: data };
|
||||
} catch (error) {
|
||||
return { error: error.status === 404 ? 'not-found' : error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIssueProjectItems(ref) {
|
||||
const client = elevatedClient();
|
||||
if (!client) {
|
||||
return { error: 'missing-token' };
|
||||
}
|
||||
try {
|
||||
const data = await client.graphql(ISSUE_PROJECT_QUERY, {
|
||||
owner: ref.owner,
|
||||
repo: ref.repo,
|
||||
number: ref.number,
|
||||
});
|
||||
return { items: data.repository.issue.projectItems.nodes };
|
||||
} catch (error) {
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFieldValue(fieldValue) {
|
||||
const candidates = [fieldValue.name, fieldValue.text, fieldValue.title, fieldValue.date];
|
||||
const text = candidates.find((value) => value != null && String(value).trim() !== '');
|
||||
if (text != null) {
|
||||
return text;
|
||||
}
|
||||
return fieldValue.number != null ? String(fieldValue.number) : null;
|
||||
}
|
||||
|
||||
function collectSetFields(item) {
|
||||
const setFields = new Map();
|
||||
for (const fieldValue of item.fieldValues.nodes) {
|
||||
const fieldName = fieldValue.field && fieldValue.field.name;
|
||||
const resolved = resolveFieldValue(fieldValue);
|
||||
if (fieldName && resolved != null) {
|
||||
setFields.set(fieldName, resolved);
|
||||
}
|
||||
}
|
||||
return setFields;
|
||||
}
|
||||
|
||||
async function collectProjectProblems(ref) {
|
||||
const result = await loadIssueProjectItems(ref);
|
||||
if (result.error) {
|
||||
if (!FAIL_WHEN_PROJECT_UNVERIFIABLE) {
|
||||
core.warning(`Skipping project validation for ${refLabel(ref)}: ${result.error}`);
|
||||
return [];
|
||||
}
|
||||
core.warning(`Project verification failed for ${refLabel(ref)}: ${result.error}`);
|
||||
const detail = result.error === 'missing-token'
|
||||
? 'the `PROJECTS_TOKEN` secret (a token with `read:project` and access to the linked repo) is not configured'
|
||||
: 'the project query could not be completed (see the workflow run logs)';
|
||||
return [`Could not verify the **${PROJECT_NAME}** project on issue ${refLabel(ref)}: ${detail}.`];
|
||||
}
|
||||
const projectItem = (result.items || []).find(
|
||||
(item) => item.project && item.project.title === PROJECT_NAME
|
||||
);
|
||||
if (!projectItem) {
|
||||
return [`Linked issue ${refLabel(ref)} is not added to the **${PROJECT_NAME}** project.`];
|
||||
}
|
||||
const setFields = collectSetFields(projectItem);
|
||||
const problems = [];
|
||||
for (const field of REQUIRED_PROJECT_FIELDS) {
|
||||
if (!setFields.has(field)) {
|
||||
problems.push(`Issue ${refLabel(ref)} is missing **${PROJECT_NAME} → ${field}**.`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
async function validateIssue(ref) {
|
||||
const loaded = await loadIssue(ref);
|
||||
if (loaded.error === 'not-found') {
|
||||
return { ref, problems: [`Linked issue ${refLabel(ref)} does not exist or is not accessible.`] };
|
||||
}
|
||||
if (loaded.error) {
|
||||
const detail = loaded.error === 'missing-token'
|
||||
? 'the `PROJECTS_TOKEN` secret is not configured'
|
||||
: 'the issue lookup could not be completed (see the workflow run logs)';
|
||||
core.warning(`Issue lookup failed for ${refLabel(ref)}: ${loaded.error}`);
|
||||
return { ref, problems: [`Could not read linked issue ${refLabel(ref)}: ${detail}.`] };
|
||||
}
|
||||
const issue = loaded.issue;
|
||||
if (issue.pull_request) {
|
||||
return { ref, problems: [`${refLabel(ref)} is a pull request, not an issue.`] };
|
||||
}
|
||||
const problems = [];
|
||||
if (!hasProperDescription(issue.body)) {
|
||||
problems.push(
|
||||
`Linked issue ${refLabel(ref)} needs a real description ` +
|
||||
`(at least ${MIN_DESCRIPTION_WORDS} words covering the problem, repro, and expected behavior).`
|
||||
);
|
||||
}
|
||||
problems.push(...(await collectProjectProblems(ref)));
|
||||
return { ref, problems };
|
||||
}
|
||||
|
||||
async function collectIssueProblems(pr) {
|
||||
const native = await nativeLinkedRefs(pr.number);
|
||||
const cross = (await isTrustedAuthor(pr))
|
||||
? crossRepoRefsFromBody(`${pr.title || ''}\n${pr.body || ''}`)
|
||||
: [];
|
||||
const refs = dedupeRefs([...native, ...cross]);
|
||||
if (refs.length === 0) {
|
||||
return [
|
||||
'No GitHub issue is linked. Link an issue in the **Development** section of the PR ' +
|
||||
'(or add `Fixes #12345` to the description). For a same-org cross-repo issue, add ' +
|
||||
'`Fixes open-metadata/<repo>#123` to the description.',
|
||||
];
|
||||
}
|
||||
const validations = await Promise.all(refs.map(validateIssue));
|
||||
const validIssues = validations.filter((validation) => validation.problems.length === 0);
|
||||
const problems = [];
|
||||
if (validIssues.length === 0) {
|
||||
for (const validation of validations) {
|
||||
problems.push(...validation.problems);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
function buildFailureComment(problems) {
|
||||
const lines = [
|
||||
CHECK_MARKER,
|
||||
'### ❌ PR checklist incomplete',
|
||||
'',
|
||||
'This PR cannot be merged until the following are addressed on its linked issue:',
|
||||
'',
|
||||
];
|
||||
for (const problem of problems) {
|
||||
lines.push(`- ${problem}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`The fields live on the **linked issue** in the **${PROJECT_NAME}** project ` +
|
||||
'(open the issue → right sidebar → **Projects**). After you set them, ' +
|
||||
'**re-run this check** (or push a commit) — issue/project changes do not re-trigger it automatically.'
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(`_Maintainers can bypass this check by adding the \`${BYPASS_LABEL}\` label._`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildSuccessComment() {
|
||||
return [
|
||||
CHECK_MARKER,
|
||||
'### ✅ PR checks passed',
|
||||
'',
|
||||
`The linked issue has a description and all required **${PROJECT_NAME}** project fields set. Thanks!`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function syncStickyComment(prNumber, body, hadFailure) {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find((comment) => comment.body && comment.body.includes(CHECK_MARKER));
|
||||
if (hadFailure) {
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
||||
} else {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
|
||||
}
|
||||
} else if (existing) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
||||
}
|
||||
}
|
||||
|
||||
const pr = await loadPullRequest();
|
||||
|
||||
if (pr.draft) {
|
||||
core.info('Draft PR — skipping metadata validation.');
|
||||
return;
|
||||
}
|
||||
if (pr.user && pr.user.type === 'Bot') {
|
||||
core.info(`PR opened by bot ${pr.user.login} — skipping metadata validation.`);
|
||||
return;
|
||||
}
|
||||
const labels = (pr.labels || []).map((label) => label.name);
|
||||
if (labels.includes(BYPASS_LABEL)) {
|
||||
core.info(`'${BYPASS_LABEL}' label present — skipping metadata validation.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const problems = [];
|
||||
if (REQUIRE_LINKED_ISSUE) {
|
||||
problems.push(...(await collectIssueProblems(pr)));
|
||||
}
|
||||
|
||||
const hadFailure = problems.length > 0;
|
||||
const body = hadFailure ? buildFailureComment(problems) : buildSuccessComment();
|
||||
await syncStickyComment(pr.number, body, hadFailure);
|
||||
|
||||
if (hadFailure) {
|
||||
core.setFailed(
|
||||
`PR metadata checklist incomplete: ${problems.length} item(s) need attention. See the PR comment.`
|
||||
);
|
||||
} else {
|
||||
core.info('All PR metadata checks passed.');
|
||||
}
|
||||
Reference in New Issue
Block a user