Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions .github/scripts/validate-structure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env node

const { execSync } = require('child_process');

const allowedCategories = new Set([
'Core ServiceNow APIs',
'Server-Side Components',
'Client-Side Components',
'Modern Development',
'Integration',
'Specialized Areas'
]);

function resolveDiffRange() {
if (process.argv[2]) {
return process.argv[2];
}

const inCI = process.env.GITHUB_ACTIONS === 'true';
if (!inCI) {
return 'origin/main...HEAD';
}

const base = process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : 'origin/main';
const head = process.env.GITHUB_SHA || 'HEAD';
return `${base}...${head}`;
}

function getChangedFiles(diffRange) {
let output;
try {
output = execSync(`git diff --name-only --diff-filter=ACMR ${diffRange}`, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
});
} catch (error) {
console.error('Failed to collect changed files. Ensure the base branch is fetched.');
console.error(error.stderr?.toString() || error.message);
process.exit(1);
}

return output
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}

function validateFilePath(filePath) {
const normalized = filePath.replace(/\\/g, '/');
const segments = normalized.split('/');

// Check for invalid characters that break local file systems
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];

// Check for trailing periods (invalid on Windows)
if (segment.endsWith('.')) {
return `Invalid folder/file name '${segment}' in path '${normalized}': Names cannot end with a period (.) as this breaks local file system sync on Windows.`;
}

// Check for trailing spaces (invalid on Windows)
if (segment.endsWith(' ')) {
return `Invalid folder/file name '${segment}' in path '${normalized}': Names cannot end with a space as this breaks local file system sync on Windows.`;
}

// Check for reserved Windows names
const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'];
const nameWithoutExt = segment.split('.')[0].toUpperCase();
if (reservedNames.includes(nameWithoutExt)) {
return `Invalid folder/file name '${segment}' in path '${normalized}': '${nameWithoutExt}' is a reserved name on Windows and will break local file system sync.`;
}

// Check for invalid characters (Windows and general file system restrictions)
const invalidChars = /[<>:"|?*\x00-\x1F]/;
if (invalidChars.test(segment)) {
return `Invalid folder/file name '${segment}' in path '${normalized}': Contains characters that are invalid on Windows file systems (< > : " | ? * or control characters).`;
}
}

if (!allowedCategories.has(segments[0])) {
return null;
}

// Files must live under: Category/Subcategory/SpecificUseCase/<file>
if (segments.length < 4) {
return `Move '${normalized}' under a valid folder hierarchy (Category/Subcategory/Use-Case/your-file). Files directly inside '${segments[0]}' or its subcategories are not allowed.`;
}

return null;
}

function main() {
const diffRange = resolveDiffRange();
const changedFiles = getChangedFiles(diffRange);

if (changedFiles.length === 0) {
console.log('No relevant file changes detected.');
return;
}

const problems = [];

for (const filePath of changedFiles) {
const issue = validateFilePath(filePath);
if (issue) {
problems.push(issue);
}
}

if (problems.length > 0) {
console.error('Folder structure violations found:');
for (const msg of problems) {
console.error(` - ${msg}`);
}
process.exit(1);
}

console.log('Folder structure looks good.');
}

main();
154 changes: 154 additions & 0 deletions .github/workflows/pr-auto-unassign-stale.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
name: Auto-unassign stale PR assignees

on:
schedule:
- cron: "*/15 * * * *" # run every 15 minutes
workflow_dispatch:
inputs:
enabled:
description: "Enable this automation"
type: boolean
default: true
max_age_minutes:
description: "Unassign if assigned longer than X minutes"
type: number
default: 60
dry_run:
description: "Preview only; do not change assignees"
type: boolean
default: false

permissions:
pull-requests: write
issues: write

env:
# Defaults (can be overridden via workflow_dispatch inputs)
ENABLED: "true"
MAX_ASSIGN_AGE_MINUTES: "60"
DRY_RUN: "false"

jobs:
sweep:
runs-on: ubuntu-latest
steps:
- name: Resolve inputs into env
run: |
# Prefer manual run inputs when present
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "ENABLED=${{ inputs.enabled }}" >> $GITHUB_ENV
echo "MAX_ASSIGN_AGE_MINUTES=${{ inputs.max_age_minutes }}" >> $GITHUB_ENV
echo "DRY_RUN=${{ inputs.dry_run }}" >> $GITHUB_ENV
fi
echo "Effective config: ENABLED=$ENABLED, MAX_ASSIGN_AGE_MINUTES=$MAX_ASSIGN_AGE_MINUTES, DRY_RUN=$DRY_RUN"

- name: Exit if disabled
if: ${{ env.ENABLED != 'true' && env.ENABLED != 'True' && env.ENABLED != 'TRUE' }}
run: echo "Disabled via ENABLED=$ENABLED. Exiting." && exit 0

- name: Unassign stale assignees
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;

const MAX_MIN = parseInt(process.env.MAX_ASSIGN_AGE_MINUTES || "60", 10);
const DRY_RUN = ["true","True","TRUE","1","yes"].includes(String(process.env.DRY_RUN));
const now = new Date();

core.info(`Scanning open PRs. Threshold = ${MAX_MIN} minutes. DRY_RUN=${DRY_RUN}`);

// List all open PRs
const prs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: "open", per_page: 100
});

let totalUnassigned = 0;

for (const pr of prs) {
if (!pr.assignees || pr.assignees.length === 0) continue;

const number = pr.number;
core.info(`PR #${number}: "${pr.title}" — assignees: ${pr.assignees.map(a => a.login).join(", ")}`);

// Pull reviews (to see if an assignee started a review)
const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner, repo, pull_number: number, per_page: 100
});

// Issue comments (general comments)
const issueComments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: number, per_page: 100
});

// Review comments (file-level)
const reviewComments = await github.paginate(github.rest.pulls.listReviewComments, {
owner, repo, pull_number: number, per_page: 100
});

// Issue events (to find assignment timestamps)
const issueEvents = await github.paginate(github.rest.issues.listEvents, {
owner, repo, issue_number: number, per_page: 100
});

for (const a of pr.assignees) {
const assignee = a.login;

// Find the most recent "assigned" event for this assignee
const assignedEvents = issueEvents
.filter(e => e.event === "assigned" && e.assignee && e.assignee.login === assignee)
.sort((x, y) => new Date(y.created_at) - new Date(x.created_at));

if (assignedEvents.length === 0) {
core.info(` - @${assignee}: no 'assigned' event found; skipping.`);
continue;
}

const assignedAt = new Date(assignedEvents[0].created_at);
const ageMin = (now - assignedAt) / 60000;

// Has the assignee commented (issue or review comments) or reviewed?
const hasIssueComment = issueComments.some(c => c.user?.login === assignee);
const hasReviewComment = reviewComments.some(c => c.user?.login === assignee);
const hasReview = reviews.some(r => r.user?.login === assignee);

const eligible =
ageMin >= MAX_MIN &&
!hasIssueComment &&
!hasReviewComment &&
!hasReview &&
pr.state === "open";

core.info(` - @${assignee}: assigned ${ageMin.toFixed(1)} min ago; commented=${hasIssueComment || hasReviewComment}; reviewed=${hasReview}; open=${pr.state==='open'} => ${eligible ? 'ELIGIBLE' : 'skip'}`);

if (!eligible) continue;

if (DRY_RUN) {
core.notice(`Would unassign @${assignee} from PR #${number}`);
} else {
try {
await github.rest.issues.removeAssignees({
owner, repo, issue_number: number, assignees: [assignee]
});
totalUnassigned += 1;
// Optional: leave a gentle heads-up comment
await github.rest.issues.createComment({
owner, repo, issue_number: number,
body: `👋 Unassigning @${assignee} due to inactivity (> ${MAX_MIN} min without comments/reviews). This PR remains open for other reviewers.`
});
core.info(` Unassigned @${assignee} from #${number}`);
} catch (err) {
core.warning(` Failed to unassign @${assignee} from #${number}: ${err.message}`);
}
}
}
}

core.summary
.addHeading('Auto-unassign report')
.addRaw(`Threshold: ${MAX_MIN} minutes\n\n`)
.addRaw(`Total unassignments: ${totalUnassigned}\n`)
.write();

result-encoding: string
Loading
Loading