Files
wehub-resource-sync bf2343b7e4
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Waiting to run
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Blocked by required conditions
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Waiting to run
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Blocked by required conditions
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Waiting to run
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Blocked by required conditions
Java Checkstyle / java-checkstyle (push) Waiting to run
Maven Collate Tests / maven-collate-ci (push) Waiting to run
OpenMetadata Service Unit Tests / Detect Changes (push) Waiting to run
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Blocked by required conditions
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Blocked by required conditions
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Blocked by required conditions
Publish Package to Maven Central Repository / publish-maven-packages (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:35:45 +08:00

425 lines
19 KiB
YAML

# 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.');
}