Skip to content

Commit fdba401

Browse files
authored
Merge branch 'ServiceNowDevProgram:main' into This-is-my-New-Branch-SK
2 parents 4a74dfa + f35b2a6 commit fdba401

File tree

86 files changed

+4443
-396
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

86 files changed

+4443
-396
lines changed
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
#!/usr/bin/env node
2+
3+
const { execSync } = require('child_process');
4+
5+
const allowedCategories = new Set([
6+
'Core ServiceNow APIs',
7+
'Server-Side Components',
8+
'Client-Side Components',
9+
'Modern Development',
10+
'Integration',
11+
'Specialized Areas'
12+
]);
13+
14+
function resolveDiffRange() {
15+
if (process.argv[2]) {
16+
return process.argv[2];
17+
}
18+
19+
const inCI = process.env.GITHUB_ACTIONS === 'true';
20+
if (!inCI) {
21+
return 'origin/main...HEAD';
22+
}
23+
24+
const base = process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : 'origin/main';
25+
const head = process.env.GITHUB_SHA || 'HEAD';
26+
return `${base}...${head}`;
27+
}
28+
29+
function getChangedFiles(diffRange) {
30+
let output;
31+
try {
32+
output = execSync(`git diff --name-only --diff-filter=ACMR ${diffRange}`, {
33+
encoding: 'utf8',
34+
stdio: ['ignore', 'pipe', 'pipe']
35+
});
36+
} catch (error) {
37+
console.error('Failed to collect changed files. Ensure the base branch is fetched.');
38+
console.error(error.stderr?.toString() || error.message);
39+
process.exit(1);
40+
}
41+
42+
return output
43+
.split('\n')
44+
.map((line) => line.trim())
45+
.filter(Boolean);
46+
}
47+
48+
function validateFilePath(filePath) {
49+
const normalized = filePath.replace(/\\/g, '/');
50+
const segments = normalized.split('/');
51+
52+
// Check for invalid characters that break local file systems
53+
for (let i = 0; i < segments.length; i++) {
54+
const segment = segments[i];
55+
56+
// Check for trailing periods (invalid on Windows)
57+
if (segment.endsWith('.')) {
58+
return `Invalid folder/file name '${segment}' in path '${normalized}': Names cannot end with a period (.) as this breaks local file system sync on Windows.`;
59+
}
60+
61+
// Check for trailing spaces (invalid on Windows)
62+
if (segment.endsWith(' ')) {
63+
return `Invalid folder/file name '${segment}' in path '${normalized}': Names cannot end with a space as this breaks local file system sync on Windows.`;
64+
}
65+
66+
// Check for reserved Windows names
67+
const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'];
68+
const nameWithoutExt = segment.split('.')[0].toUpperCase();
69+
if (reservedNames.includes(nameWithoutExt)) {
70+
return `Invalid folder/file name '${segment}' in path '${normalized}': '${nameWithoutExt}' is a reserved name on Windows and will break local file system sync.`;
71+
}
72+
73+
// Check for invalid characters (Windows and general file system restrictions)
74+
const invalidChars = /[<>:"|?*\x00-\x1F]/;
75+
if (invalidChars.test(segment)) {
76+
return `Invalid folder/file name '${segment}' in path '${normalized}': Contains characters that are invalid on Windows file systems (< > : " | ? * or control characters).`;
77+
}
78+
}
79+
80+
if (!allowedCategories.has(segments[0])) {
81+
return null;
82+
}
83+
84+
// Files must live under: Category/Subcategory/SpecificUseCase/<file>
85+
if (segments.length < 4) {
86+
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.`;
87+
}
88+
89+
return null;
90+
}
91+
92+
function main() {
93+
const diffRange = resolveDiffRange();
94+
const changedFiles = getChangedFiles(diffRange);
95+
96+
if (changedFiles.length === 0) {
97+
console.log('No relevant file changes detected.');
98+
return;
99+
}
100+
101+
const problems = [];
102+
103+
for (const filePath of changedFiles) {
104+
const issue = validateFilePath(filePath);
105+
if (issue) {
106+
problems.push(issue);
107+
}
108+
}
109+
110+
if (problems.length > 0) {
111+
console.error('Folder structure violations found:');
112+
for (const msg of problems) {
113+
console.error(` - ${msg}`);
114+
}
115+
process.exit(1);
116+
}
117+
118+
console.log('Folder structure looks good.');
119+
}
120+
121+
main();
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
name: Auto-unassign stale PR assignees
2+
3+
on:
4+
schedule:
5+
- cron: "*/15 * * * *" # run every 15 minutes
6+
workflow_dispatch:
7+
inputs:
8+
enabled:
9+
description: "Enable this automation"
10+
type: boolean
11+
default: true
12+
max_age_minutes:
13+
description: "Unassign if assigned longer than X minutes"
14+
type: number
15+
default: 60
16+
dry_run:
17+
description: "Preview only; do not change assignees"
18+
type: boolean
19+
default: false
20+
21+
permissions:
22+
pull-requests: write
23+
issues: write
24+
25+
env:
26+
# Defaults (can be overridden via workflow_dispatch inputs)
27+
ENABLED: "true"
28+
MAX_ASSIGN_AGE_MINUTES: "60"
29+
DRY_RUN: "false"
30+
31+
jobs:
32+
sweep:
33+
runs-on: ubuntu-latest
34+
steps:
35+
- name: Resolve inputs into env
36+
run: |
37+
# Prefer manual run inputs when present
38+
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
39+
echo "ENABLED=${{ inputs.enabled }}" >> $GITHUB_ENV
40+
echo "MAX_ASSIGN_AGE_MINUTES=${{ inputs.max_age_minutes }}" >> $GITHUB_ENV
41+
echo "DRY_RUN=${{ inputs.dry_run }}" >> $GITHUB_ENV
42+
fi
43+
echo "Effective config: ENABLED=$ENABLED, MAX_ASSIGN_AGE_MINUTES=$MAX_ASSIGN_AGE_MINUTES, DRY_RUN=$DRY_RUN"
44+
45+
- name: Exit if disabled
46+
if: ${{ env.ENABLED != 'true' && env.ENABLED != 'True' && env.ENABLED != 'TRUE' }}
47+
run: echo "Disabled via ENABLED=$ENABLED. Exiting." && exit 0
48+
49+
- name: Unassign stale assignees
50+
uses: actions/github-script@v7
51+
with:
52+
script: |
53+
const owner = context.repo.owner;
54+
const repo = context.repo.repo;
55+
56+
const MAX_MIN = parseInt(process.env.MAX_ASSIGN_AGE_MINUTES || "60", 10);
57+
const DRY_RUN = ["true","True","TRUE","1","yes"].includes(String(process.env.DRY_RUN));
58+
const now = new Date();
59+
60+
core.info(`Scanning open PRs. Threshold = ${MAX_MIN} minutes. DRY_RUN=${DRY_RUN}`);
61+
62+
// List all open PRs
63+
const prs = await github.paginate(github.rest.pulls.list, {
64+
owner, repo, state: "open", per_page: 100
65+
});
66+
67+
let totalUnassigned = 0;
68+
69+
for (const pr of prs) {
70+
if (!pr.assignees || pr.assignees.length === 0) continue;
71+
72+
const number = pr.number;
73+
core.info(`PR #${number}: "${pr.title}" — assignees: ${pr.assignees.map(a => a.login).join(", ")}`);
74+
75+
// Pull reviews (to see if an assignee started a review)
76+
const reviews = await github.paginate(github.rest.pulls.listReviews, {
77+
owner, repo, pull_number: number, per_page: 100
78+
});
79+
80+
// Issue comments (general comments)
81+
const issueComments = await github.paginate(github.rest.issues.listComments, {
82+
owner, repo, issue_number: number, per_page: 100
83+
});
84+
85+
// Review comments (file-level)
86+
const reviewComments = await github.paginate(github.rest.pulls.listReviewComments, {
87+
owner, repo, pull_number: number, per_page: 100
88+
});
89+
90+
// Issue events (to find assignment timestamps)
91+
const issueEvents = await github.paginate(github.rest.issues.listEvents, {
92+
owner, repo, issue_number: number, per_page: 100
93+
});
94+
95+
for (const a of pr.assignees) {
96+
const assignee = a.login;
97+
98+
// Find the most recent "assigned" event for this assignee
99+
const assignedEvents = issueEvents
100+
.filter(e => e.event === "assigned" && e.assignee && e.assignee.login === assignee)
101+
.sort((x, y) => new Date(y.created_at) - new Date(x.created_at));
102+
103+
if (assignedEvents.length === 0) {
104+
core.info(` - @${assignee}: no 'assigned' event found; skipping.`);
105+
continue;
106+
}
107+
108+
const assignedAt = new Date(assignedEvents[0].created_at);
109+
const ageMin = (now - assignedAt) / 60000;
110+
111+
// Has the assignee commented (issue or review comments) or reviewed?
112+
const hasIssueComment = issueComments.some(c => c.user?.login === assignee);
113+
const hasReviewComment = reviewComments.some(c => c.user?.login === assignee);
114+
const hasReview = reviews.some(r => r.user?.login === assignee);
115+
116+
const eligible =
117+
ageMin >= MAX_MIN &&
118+
!hasIssueComment &&
119+
!hasReviewComment &&
120+
!hasReview &&
121+
pr.state === "open";
122+
123+
core.info(` - @${assignee}: assigned ${ageMin.toFixed(1)} min ago; commented=${hasIssueComment || hasReviewComment}; reviewed=${hasReview}; open=${pr.state==='open'} => ${eligible ? 'ELIGIBLE' : 'skip'}`);
124+
125+
if (!eligible) continue;
126+
127+
if (DRY_RUN) {
128+
core.notice(`Would unassign @${assignee} from PR #${number}`);
129+
} else {
130+
try {
131+
await github.rest.issues.removeAssignees({
132+
owner, repo, issue_number: number, assignees: [assignee]
133+
});
134+
totalUnassigned += 1;
135+
// Optional: leave a gentle heads-up comment
136+
await github.rest.issues.createComment({
137+
owner, repo, issue_number: number,
138+
body: `👋 Unassigning @${assignee} due to inactivity (> ${MAX_MIN} min without comments/reviews). This PR remains open for other reviewers.`
139+
});
140+
core.info(` Unassigned @${assignee} from #${number}`);
141+
} catch (err) {
142+
core.warning(` Failed to unassign @${assignee} from #${number}: ${err.message}`);
143+
}
144+
}
145+
}
146+
}
147+
148+
core.summary
149+
.addHeading('Auto-unassign report')
150+
.addRaw(`Threshold: ${MAX_MIN} minutes\n\n`)
151+
.addRaw(`Total unassignments: ${totalUnassigned}\n`)
152+
.write();
153+
154+
result-encoding: string

0 commit comments

Comments
 (0)