mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-09 03:49:52 +02:00
<!-- Enter a brief description/summary of your PR here. What does it fix/what does it change/how was it tested (even manually, if necessary)? --> ## Summary of the Pull Request Transitions the telemetry PR detection workflow from testing phase to ready status. All components are now fully functional and automatically triggered on every new PR. - Skips checks on draft PRs to avoid noise - Prevents multiple concurrent runs per PR - Safely requests `@chatasweetie` as reviewer on telemetry changes - update commit messages <!-- Please review the items on the PR checklist before submitting--> ## PR Checklist - [ ] Closes: #xxx <!-- - [ ] Closes: #yyy (add separate lines for additional resolved issues) --> - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx <!-- Provide a more detailed description of the PR, other things fixed, or any additional comments/features here --> ## Detailed Description of the Pull Request / Additional comments <!-- Describe how you validated the behavior. Add automated tests wherever possible, but list manual validation steps taken as well --> ## Validation Steps Performed --------- Co-authored-by: Carlos Zamora <carlos.zamora@microsoft.com>
378 lines
12 KiB
JavaScript
378 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Detects telemetry-event additions/modifications in a pull request and
|
|
* posts (or updates) a PR comment when telemetry-related changes are found.
|
|
*
|
|
* This script is executed by .github/workflows/telemetry-pr-check.yml.
|
|
* Keep both files aligned when changing trigger behavior, env usage, or messaging.
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const REVIEWER_LOGIN = 'chatasweetie';
|
|
const REVIEWER_MENTION = `@${REVIEWER_LOGIN}`;
|
|
|
|
const COMMENT_MARKER = '<!-- telemetry-event-check -->';
|
|
const COMMENT_BODY_WITH_PRIVACY_UPDATE = `${COMMENT_MARKER}
|
|
Thank you for contributing to PowerToys. We've detected that this PR might include a new or modified telemetry event. After this PR is merged, please follow these next steps:
|
|
|
|
- [ ] Reach out to Jessica (${REVIEWER_MENTION}) to follow up on the next steps: https://aka.ms/pt-telemetry-process
|
|
`;
|
|
|
|
const COMMENT_BODY_WITHOUT_PRIVACY_UPDATE = `${COMMENT_MARKER}
|
|
Thank you for contributing to PowerToys. We've detected that this PR might include a new or modified telemetry event. Please ensure the following before merging:
|
|
|
|
- [ ] Add your telemetry events to [DATA_AND_PRIVACY](https://github.com/microsoft/PowerToys/blob/main/DATA_AND_PRIVACY.md).md within this PR.
|
|
|
|
- [ ] Reach out to Jessica (${REVIEWER_MENTION}) to follow up on the next steps: https://aka.ms/pt-telemetry-process`;
|
|
|
|
const TELEMETRY_PATH_PATTERNS = [
|
|
/(^|\/)trace\.(h|hpp|cpp|cs)$/i,
|
|
/(^|\/)telemetry\//i,
|
|
/(^|\/)events\/.+event\.cs$/i,
|
|
/^src\/common\/Telemetry\//i,
|
|
/^src\/common\/ManagedTelemetry\//i,
|
|
/^src\/runner\/trace\.(h|cpp)$/i,
|
|
/^src\/settings-ui\/.+\/Telemetry\//i,
|
|
];
|
|
|
|
const TELEMETRY_LINE_PATTERNS = [
|
|
/TraceLoggingWriteWrapper\s*\(/,
|
|
/\bTraceLoggingWrite\s*\(/,
|
|
/\bTRACELOGGING_DEFINE_PROVIDER\b/,
|
|
/\bTraceLoggingOptionProjectTelemetry\b/,
|
|
/\bProjectTelemetryPrivacyDataTag\b/,
|
|
/\bPROJECT_KEYWORD_MEASURE\b/,
|
|
/\bRegisterProvider\s*\(/,
|
|
/\bUnregisterProvider\s*\(/,
|
|
/\bPowerToysTelemetry\.Log\.WriteEvent\s*\(/,
|
|
/\bclass\s+\w+\s*:\s*EventBase\s*,\s*IEvent\b/,
|
|
/\bclass\s+\w+\s*:\s*TelemetryBase\b/,
|
|
/\bPartA_PrivTags\b/,
|
|
/\[EventData\]/,
|
|
/\bEventName\b/,
|
|
];
|
|
|
|
function requireEnv(name) {
|
|
const value = process.env[name];
|
|
if (!value) {
|
|
throw new Error(`Missing required environment variable: ${name}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateRepository(repository) {
|
|
if (!/^[^/]+\/[^/]+$/.test(repository)) {
|
|
throw new Error(
|
|
`GITHUB_REPOSITORY must be in owner/repo format, received: ${JSON.stringify(repository)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
function readEventPayload(eventPath) {
|
|
let raw;
|
|
try {
|
|
raw = fs.readFileSync(eventPath, 'utf8');
|
|
} catch (error) {
|
|
throw new Error(`Failed to read event payload at ${eventPath}: ${error.message}`);
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch (error) {
|
|
throw new Error(`Failed to parse JSON from ${eventPath}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function resolvePullNumber(event) {
|
|
const fromPullRequest = event?.pull_request?.number;
|
|
const fromWorkflowDispatch = event?.inputs?.pr_number;
|
|
const rawPullNumber = fromPullRequest ?? fromWorkflowDispatch;
|
|
|
|
if (rawPullNumber === undefined || rawPullNumber === null || rawPullNumber === '') {
|
|
throw new Error(
|
|
'Unable to determine pull request number from event payload. Expected pull_request.number or inputs.pr_number.'
|
|
);
|
|
}
|
|
|
|
const pullNumber = Number.parseInt(String(rawPullNumber), 10);
|
|
if (!Number.isInteger(pullNumber) || pullNumber <= 0) {
|
|
throw new Error(`Invalid pull request number: ${JSON.stringify(rawPullNumber)}`);
|
|
}
|
|
|
|
return pullNumber;
|
|
}
|
|
|
|
function isTelemetryPath(filePath) {
|
|
return TELEMETRY_PATH_PATTERNS.some((pattern) => pattern.test(filePath));
|
|
}
|
|
|
|
function changedLinesFromPatch(patch) {
|
|
if (!patch) {
|
|
return [];
|
|
}
|
|
|
|
return patch
|
|
.split('\n')
|
|
.filter((line) => {
|
|
if (line.startsWith('+++') || line.startsWith('---')) {
|
|
return false;
|
|
}
|
|
return line.startsWith('+') || line.startsWith('-');
|
|
})
|
|
.map((line) => line.slice(1));
|
|
}
|
|
|
|
function hasTelemetryLineSignal(lines) {
|
|
return lines.some((line) => TELEMETRY_LINE_PATTERNS.some((pattern) => pattern.test(line)));
|
|
}
|
|
|
|
async function apiRequest(url, method = 'GET', body) {
|
|
const token = requireEnv('GITHUB_TOKEN');
|
|
let response;
|
|
try {
|
|
response = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
Accept: 'application/vnd.github+json',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
} catch (error) {
|
|
throw new Error(`Network error during ${method} ${url}: ${error.message}`);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
const rateLimitReset = response.headers.get('x-ratelimit-reset');
|
|
const rateLimitHint =
|
|
response.status === 403 && rateLimitReset
|
|
? ` (rate limit reset at epoch ${rateLimitReset})`
|
|
: '';
|
|
throw new Error(`${method} ${url} failed (${response.status})${rateLimitHint}: ${text}`);
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return await response.json();
|
|
} catch (error) {
|
|
throw new Error(`Failed to parse JSON response for ${method} ${url}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function getAllPullFiles(apiBaseUrl, repository, pullNumber) {
|
|
const files = [];
|
|
let page = 1;
|
|
|
|
while (true) {
|
|
const url = `${apiBaseUrl}/repos/${repository}/pulls/${pullNumber}/files?per_page=100&page=${page}`;
|
|
const batch = await apiRequest(url);
|
|
if (!Array.isArray(batch)) {
|
|
throw new Error(`Unexpected response while listing PR files on page ${page}.`);
|
|
}
|
|
|
|
if (batch.length === 0) {
|
|
break;
|
|
}
|
|
|
|
files.push(...batch);
|
|
|
|
if (batch.length < 100) {
|
|
break;
|
|
}
|
|
|
|
page += 1;
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
async function getPullRequest(apiBaseUrl, repository, pullNumber) {
|
|
const url = `${apiBaseUrl}/repos/${repository}/pulls/${pullNumber}`;
|
|
const pullRequest = await apiRequest(url);
|
|
if (!pullRequest || typeof pullRequest !== 'object') {
|
|
throw new Error('Unexpected response while fetching pull request details.');
|
|
}
|
|
return pullRequest;
|
|
}
|
|
|
|
async function ensureReviewerRequested(apiBaseUrl, repository, pullNumber, pullRequest) {
|
|
const authorLogin = String(pullRequest?.user?.login || '').toLowerCase();
|
|
const targetReviewer = REVIEWER_LOGIN.toLowerCase();
|
|
|
|
if (authorLogin === targetReviewer) {
|
|
console.log(`Skipping reviewer request: ${REVIEWER_LOGIN} is the PR author.`);
|
|
return;
|
|
}
|
|
|
|
const requestedReviewers = Array.isArray(pullRequest?.requested_reviewers)
|
|
? pullRequest.requested_reviewers
|
|
: [];
|
|
const alreadyRequested = requestedReviewers.some(
|
|
(reviewer) => String(reviewer?.login || '').toLowerCase() === targetReviewer
|
|
);
|
|
|
|
if (alreadyRequested) {
|
|
console.log(`Reviewer ${REVIEWER_LOGIN} is already requested.`);
|
|
return;
|
|
}
|
|
|
|
const url = `${apiBaseUrl}/repos/${repository}/pulls/${pullNumber}/requested_reviewers`;
|
|
try {
|
|
await apiRequest(url, 'POST', { reviewers: [REVIEWER_LOGIN] });
|
|
console.log(`Requested reviewer ${REVIEWER_LOGIN}.`);
|
|
} catch (error) {
|
|
// Reviewer request should not fail the telemetry guidance workflow.
|
|
console.warn(
|
|
`Unable to request reviewer ${REVIEWER_LOGIN}: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
async function findExistingTelemetryComment(apiBaseUrl, repository, pullNumber) {
|
|
let page = 1;
|
|
|
|
while (true) {
|
|
const commentsUrl = `${apiBaseUrl}/repos/${repository}/issues/${pullNumber}/comments?per_page=100&page=${page}`;
|
|
const comments = await apiRequest(commentsUrl);
|
|
|
|
if (!Array.isArray(comments)) {
|
|
throw new Error(`Unexpected response while listing issue comments on page ${page}.`);
|
|
}
|
|
|
|
const existing = comments.find(
|
|
(comment) => typeof comment.body === 'string' && comment.body.includes(COMMENT_MARKER)
|
|
);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
if (comments.length < 100) {
|
|
return null;
|
|
}
|
|
|
|
page += 1;
|
|
}
|
|
}
|
|
|
|
function detectTelemetryChanges(files) {
|
|
const matches = [];
|
|
|
|
for (const file of files) {
|
|
const filename = file.filename || '';
|
|
const telemetryPath = isTelemetryPath(filename);
|
|
const changedLines = changedLinesFromPatch(file.patch);
|
|
const telemetryLineSignal = hasTelemetryLineSignal(changedLines);
|
|
|
|
// Some large diffs omit patch content. If the file path is telemetry-centric,
|
|
// treat it as a telemetry modification to avoid false negatives.
|
|
const patchUnavailable = !file.patch && telemetryPath;
|
|
|
|
if (telemetryPath || telemetryLineSignal || patchUnavailable) {
|
|
matches.push({
|
|
filename,
|
|
telemetryPath,
|
|
telemetryLineSignal,
|
|
patchUnavailable,
|
|
});
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
function hasDataAndPrivacyChange(files) {
|
|
return files.some((file) => {
|
|
const filename = (file.filename || '').toLowerCase();
|
|
return filename === 'data_and_privacy.md';
|
|
});
|
|
}
|
|
|
|
async function upsertPrComment(apiBaseUrl, repository, pullNumber, body) {
|
|
const existing = await findExistingTelemetryComment(apiBaseUrl, repository, pullNumber);
|
|
|
|
if (existing) {
|
|
const updateUrl = `${apiBaseUrl}/repos/${repository}/issues/comments/${existing.id}`;
|
|
await apiRequest(updateUrl, 'PATCH', { body });
|
|
console.log(`Updated existing telemetry comment (id: ${existing.id}).`);
|
|
return;
|
|
}
|
|
|
|
const createUrl = `${apiBaseUrl}/repos/${repository}/issues/${pullNumber}/comments`;
|
|
await apiRequest(createUrl, 'POST', { body });
|
|
console.log('Created telemetry comment on PR.');
|
|
}
|
|
|
|
async function main() {
|
|
const eventPath = requireEnv('GITHUB_EVENT_PATH');
|
|
const repository = requireEnv('GITHUB_REPOSITORY');
|
|
const apiBaseUrl = process.env.GITHUB_API_URL || 'https://api.github.com';
|
|
validateRepository(repository);
|
|
|
|
let parsedApiBaseUrl;
|
|
try {
|
|
parsedApiBaseUrl = new URL(apiBaseUrl);
|
|
} catch {
|
|
throw new Error(`Invalid GITHUB_API_URL: ${JSON.stringify(apiBaseUrl)}`);
|
|
}
|
|
|
|
const event = readEventPayload(eventPath);
|
|
const pullNumber = resolvePullNumber(event);
|
|
|
|
console.log(`Event name: ${process.env.GITHUB_EVENT_NAME || 'unknown'}`);
|
|
console.log(`Repository: ${repository}`);
|
|
console.log(`PR number: ${pullNumber}`);
|
|
|
|
const files = await getAllPullFiles(parsedApiBaseUrl.origin, repository, pullNumber);
|
|
|
|
if (files.length === 0) {
|
|
console.log('No changed files found for PR; skipping telemetry comment update.');
|
|
return;
|
|
}
|
|
|
|
const matches = detectTelemetryChanges(files);
|
|
const dataAndPrivacyChanged = hasDataAndPrivacyChange(files);
|
|
|
|
console.log(`Scanned ${files.length} changed files.`);
|
|
console.log(`Telemetry matches found: ${matches.length}.`);
|
|
console.log(`DATA_AND_PRIVACY.md changed: ${dataAndPrivacyChanged}.`);
|
|
|
|
if (matches.length === 0) {
|
|
console.log('No telemetry-related additions/modifications detected.');
|
|
return;
|
|
}
|
|
|
|
for (const match of matches) {
|
|
console.log(
|
|
`- ${match.filename} (telemetryPath=${match.telemetryPath}, telemetryLineSignal=${match.telemetryLineSignal}, patchUnavailable=${match.patchUnavailable})`
|
|
);
|
|
}
|
|
|
|
try {
|
|
const pullRequest = await getPullRequest(parsedApiBaseUrl.origin, repository, pullNumber);
|
|
await ensureReviewerRequested(parsedApiBaseUrl.origin, repository, pullNumber, pullRequest);
|
|
} catch (error) {
|
|
console.warn(
|
|
'Failed to fetch PR details or request reviewer; continuing to post telemetry guidance comment.'
|
|
);
|
|
console.warn(error instanceof Error ? error.stack || error.message : error);
|
|
}
|
|
|
|
const commentBody = dataAndPrivacyChanged
|
|
? COMMENT_BODY_WITH_PRIVACY_UPDATE
|
|
: COMMENT_BODY_WITHOUT_PRIVACY_UPDATE;
|
|
|
|
await upsertPrComment(apiBaseUrl, repository, pullNumber, commentBody);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error('Telemetry PR check failed.');
|
|
console.error(error instanceof Error ? error.stack || error.message : error);
|
|
process.exit(1);
|
|
});
|