Skip to content

[Project] Community Infrastructure Management Portal #1163 #958

[Project] Community Infrastructure Management Portal #1163

[Project] Community Infrastructure Management Portal #1163 #958

Workflow file for this run

name: PR Validation
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
contents: write
issues: write
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Validate project submission
uses: actions/github-script@v7
with:
github-token: ${{ secrets.MERGE_PAT }}
script: |
const { owner, repo } = context.repo;
const prNum = context.payload.pull_request.number;
const prHead = context.payload.pull_request.head.sha;
const prAuthor = context.payload.pull_request.user.login;
// ── Collect all changed files ─────────────────────────────────
const { data: files } = await github.rest.pulls.listFiles({
owner, repo, pull_number: prNum,
});
const normalized = files.map(f => ({
...f,
fl: f.filename.toLowerCase(),
st: (f.status || 'added').toLowerCase(),
}));
const errors = [];
// ── SECTION 1: File scope ─────────────────────────────────────
const outsideProjects = files.some(f => !f.filename.startsWith('Projects/'));
const rootJsonTouched = files.some(f => f.filename === 'project.json');
if (rootJsonTouched || outsideProjects) {
errors.push(
'All changes must be inside a new project folder under `Projects/`. ' +
'Do not modify root-level files or any file outside your project folder.'
);
}
const projectDirs = new Set();
for (const f of normalized) {
if (f.filename.startsWith('Projects/')) {
const parts = f.filename.split('/');
if (parts.length >= 2) projectDirs.add(parts.slice(0, 2).join('/'));
}
}
if (projectDirs.size === 0) {
errors.push(
'No files were found under `Projects/`. ' +
'Place your project inside a new folder in `Projects/`.'
);
} else if (projectDirs.size > 1) {
errors.push(
`This PR touches ${projectDirs.size} different project folders. ` +
'A single PR must contain exactly one new project.'
);
}
if (files.length > 6) {
errors.push(
`This PR contains ${files.length} files. ` +
'The maximum for a single project submission is 6 files. ' +
'Remove any files that are not part of the project.'
);
}
if (projectDirs.size !== 1) {
await postResult(errors, null, prAuthor);
return;
}
const [projectDir] = [...projectDirs];
const folderName = projectDir.split('/')[1];
// ── SECTION 2: Folder name ────────────────────────────────────
function isTitleCase(name) {
if (/[-_]/.test(name)) return false;
return name.split(' ').every(w => /^[A-Z0-9][A-Za-z0-9]*$/.test(w));
}
if (!isTitleCase(folderName)) {
errors.push(
`The folder name "${folderName}" does not follow the required format. ` +
'Folder names must be Title Case with real spaces, e.g. "To Do Web App". ' +
'Hyphens and underscores are not allowed.'
);
}
// ── SECTION 3: Required files ─────────────────────────────────
const dirLower = projectDir.toLowerCase();
const hasReadme = normalized.some(f => f.fl === `${dirLower}/readme.md`);
const hasProjectJson = normalized.some(f => f.fl === `${dirLower}/project.json`);
const hasModified = normalized.some(
f => f.fl.startsWith(`${dirLower}/`) && f.st !== 'added'
);
if (!hasReadme) {
errors.push('A `README.md` file is required inside your project folder.');
}
if (!hasProjectJson) {
errors.push('A `project.json` metadata file is required inside your project folder.');
}
if (hasModified) {
errors.push(
'This PR modifies one or more existing files. ' +
'A project submission must only add new files — no edits to existing files are permitted.'
);
}
// ── SECTION 4: project.json content validation ────────────────
if (hasProjectJson) {
let meta = null;
try {
const { data: fileData } = await github.rest.repos.getContent({
owner, repo,
path: `${projectDir}/project.json`,
ref: prHead,
});
const raw = Buffer.from(fileData.content, 'base64').toString('utf8');
meta = JSON.parse(raw);
} catch (e) {
errors.push(
`\`project.json\` could not be read or parsed: ${e.message}. ` +
'Ensure the file contains valid JSON.'
);
}
if (meta) {
const REQUIRED = ['title', 'description', 'author', 'tags', 'entry'];
for (const field of REQUIRED) {
if (!meta[field]) {
errors.push(`\`project.json\` is missing the required field \`${field}\`.`);
}
}
if (meta.title && meta.title !== folderName) {
errors.push(
`\`project.json\` title "${meta.title}" must exactly match the folder name "${folderName}".`
);
}
if (meta.author && typeof meta.author === 'object') {
if (!meta.author.name) {
errors.push('`project.json` author object is missing `name`.');
}
if (!meta.author.github) {
errors.push('`project.json` author object is missing `github` (your GitHub username).');
}
}
if (meta.tags !== undefined) {
if (!Array.isArray(meta.tags)) {
errors.push('`project.json` field `tags` must be a JSON array.');
} else if (meta.tags.length < 1 || meta.tags.length > 6) {
errors.push(
`\`project.json\` \`tags\` must have between 1 and 6 entries (found ${meta.tags.length}).`
);
}
}
if (meta.entry) {
const entryLower = `${dirLower}/${meta.entry.toLowerCase()}`;
const entryInPR = normalized.some(f => f.fl === entryLower);
if (!entryInPR) {
errors.push(
`\`project.json\` lists "${meta.entry}" as the entry point, ` +
'but that file was not found in this PR. Make sure the file is included.'
);
}
}
}
}
// ── Post result ───────────────────────────────────────────────
await postResult(errors, projectDir, prAuthor);
// ── Helpers ───────────────────────────────────────────────────
async function postResult(errs, dir, author) {
if (errs.length === 0) {
// ── PASS: add labels, comment, then merge ─────────────────
await github.rest.issues.addLabels({
owner, repo, issue_number: prNum,
labels: ['level3', "NSoC'26 Accepted", "NSoC'26", 'nsoc26'],
}).catch(e => core.warning(`Label error: ${e.message}`));
await github.rest.issues.createComment({
owner, repo, issue_number: prNum,
body: [
'**PR Validation — All Checks Passed ✅**',
'',
`@${author}, your project submission has passed all validation checks!`,
'',
'Your PR has been approved and will now be merged automatically.',
'A thumbnail will be auto-generated for your project once the merge is complete.',
'',
'Thank you for contributing to OpenStudio 🎉',
].join('\n'),
});
// Auto-merge the PR
await github.rest.pulls.merge({
owner, repo,
pull_number: prNum,
merge_method: 'squash',
commit_title: `Add project: ${dir ? dir.split('/')[1] : 'submission'} (#${prNum})`,
commit_message: `Submitted by @${author}`,
}).catch(e => core.warning(`Merge error: ${e.message}`));
} else {
// ── FAIL: comment only, no labels, no merge ───────────────
const lines = [
'**PR Validation — Checks Failed ❌**',
'',
'The following issues must be resolved before this PR can be merged.',
'Fix them in your branch and push a new commit — this check will re-run automatically.',
'',
];
for (const e of errs) lines.push(`- ${e}`);
lines.push(
'',
'Refer to [CONTRIBUTING.md](../../blob/main/CONTRIBUTING.md) for the full submission requirements.'
);
await github.rest.issues.createComment({
owner, repo, issue_number: prNum,
body: lines.join('\n'),
});
}
}