The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/.github/workflows/scripts/utils.mts

517 lines
15 KiB

import { readFileSync, appendFileSync, existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import crypto from 'node:crypto';
// =============================================================================
// TYPES
// =============================================================================
export interface TeamConfig {
name: string;
adoption_date: string;
codeowners_teams?: string[];
area_labels: string[];
enabled: {
pr_notify: boolean;
pr_weekly: boolean;
fr_notify: boolean;
fr_weekly: boolean;
};
}
export interface CodeownersEntry {
pattern: RegExp;
rawPattern: string;
owners: string[];
}
export interface Config {
teams: TeamConfig[];
}
export interface FeatureRequest {
number: number;
title: string;
body: string;
url: string;
createdAt: string;
labels: string[];
thumbs_up: number;
comments: number;
}
export interface PullRequest {
number: number;
title: string;
author: { login: string };
createdAt: string;
updatedAt: string;
additions: number;
deletions: number;
files: { path: string }[];
labels: { name: string }[];
reviewDecision: string | null;
}
export interface Cluster {
name: string;
issue_numbers: number[];
}
export interface PRCluster {
name: string;
pr_numbers: number[];
}
export interface ClusterResult {
clusters: Cluster[];
}
export interface PRClusterResult {
clusters: PRCluster[];
}
export interface SlackBlock {
type: string;
text?: { type: string; text: string; emoji?: boolean };
fields?: { type: string; text: string }[];
elements?: { type: string; text: string }[];
}
// =============================================================================
// CONFIGURATION
// =============================================================================
const CONFIG_FILE = '.github/team-notifications-config.json';
export function loadConfig(): Config {
if (!existsSync(CONFIG_FILE)) {
log.error(`Configuration file not found: ${CONFIG_FILE}`);
process.exit(1);
}
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) as Config;
}
// =============================================================================
// ENVIRONMENT HELPERS
// =============================================================================
export function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
log.error(`Required environment variable ${name} is not set`);
process.exit(1);
}
return value;
}
// =============================================================================
// GITHUB ACTIONS LOGGING
// =============================================================================
export const log = {
notice: (msg: string) => console.log(`::notice::${msg}`),
warning: (msg: string) => console.log(`::warning::${msg}`),
error: (msg: string) => console.log(`::error::${msg}`),
groupStart: (name: string) => console.log(`::group::${name}`),
groupEnd: () => console.log('::endgroup::'),
};
// =============================================================================
// GITHUB ACTIONS OUTPUT
// =============================================================================
export function setOutput(key: string, value: string): void {
const outputFile = process.env.GITHUB_OUTPUT;
const safeValue = value.replace(/[\r\n]/g, '');
if (outputFile) {
appendFileSync(outputFile, `${key}=${safeValue}\n`);
}
console.log(`Output: ${key}=${safeValue}`);
}
export function setOutputMultiline(key: string, value: string): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
const delimiter = `ghadelim_${crypto.randomUUID().replace(/-/g, '')}`;
appendFileSync(outputFile, `${key}<<${delimiter}\n${value}\n${delimiter}\n`);
}
}
// =============================================================================
// INPUT VALIDATION
// =============================================================================
export function stripMarkdown(text: string): string {
return text
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/<[^>]+>/g, '')
.replace(/#{1,6}\s*/g, '')
.replace(/[*_~`]/g, '')
.replace(/\r?\n/g, ' ');
}
export function teamChannelEnv(teamName: string, type: 'pr' | 'fr'): string {
const raw = process.env.SLACK_CHANNELS ?? '';
if (!raw) return '';
let map: unknown;
try {
map = JSON.parse(raw);
} catch {
return '';
}
if (typeof map !== 'object' || map === null || Array.isArray(map)) return '';
const team = (map as Record<string, unknown>)[teamName];
if (typeof team !== 'object' || team === null || Array.isArray(team)) return '';
const value = (team as Record<string, unknown>)[type];
if (typeof value !== 'string' || !/^C[A-Z0-9]+$/.test(value)) return '';
return value;
}
export function sanitizeInput(input: string, maxLength: number = 200): string {
if (!input) return '';
let s = input.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
s = s.replace(
/(ignore|disregard|forget|override|bypass|skip|new instruction|system:|assistant:|user:)\s*(all|any|the|previous|above|prior|my)?\s*(instruction|command|rule|prompt|message|context)s?/gi,
'',
);
s = s.replace(/```/g, '');
s = s.replace(/\s+/g, ' ').trim();
return s.slice(0, maxLength);
}
export function sanitizeForSlack(text: string): string {
return text
.replace(/@channel/gi, '')
.replace(/@here/gi, '')
.replace(/@everyone/gi, '')
.replace(/@all/gi, '')
.replace(/<!channel>/gi, '')
.replace(/<!here>/gi, '')
.replace(/<!everyone>/gi, '')
.replace(/<@[A-Z0-9]+>/gi, '')
.replace(/<![a-z]+\|[^>]*>/gi, '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
export function isValidIssueNumber(num: unknown): num is number {
return typeof num === 'number' && Number.isInteger(num) && num > 0 && num < 1_000_000_000;
}
export function isValidGitHubUsername(username: string): boolean {
return /^[a-zA-Z0-9-]{1,39}$/.test(username);
}
export function validateFRClusterResponse(response: string): ClusterResult {
const empty: ClusterResult = { clusters: [] };
let parsed: unknown;
try {
parsed = JSON.parse(response);
} catch {
return empty;
}
if (!parsed || typeof parsed !== 'object' || !Array.isArray((parsed as Record<string, unknown>).clusters)) {
return empty;
}
const clusters: Cluster[] = [];
for (const raw of (parsed as { clusters: unknown[] }).clusters) {
if (!raw || typeof raw !== 'object') continue;
const entry = raw as Record<string, unknown>;
const name = String(entry.name ?? 'Unnamed cluster').replace(/[\x00-\x1F]/g, '').slice(0, 100);
const issueNumbers: number[] = Array.isArray(entry.issue_numbers)
? entry.issue_numbers.filter(isValidIssueNumber)
: [];
if (issueNumbers.length >= 2 && issueNumbers.length <= 6) {
clusters.push({ name, issue_numbers: issueNumbers });
}
}
return { clusters };
}
export function validatePRClusterResponse(response: string): PRClusterResult {
const empty: PRClusterResult = { clusters: [] };
let parsed: unknown;
try {
parsed = JSON.parse(response);
} catch {
return empty;
}
if (!parsed || typeof parsed !== 'object' || !Array.isArray((parsed as Record<string, unknown>).clusters)) {
return empty;
}
const clusters: PRCluster[] = [];
for (const raw of (parsed as { clusters: unknown[] }).clusters) {
if (!raw || typeof raw !== 'object') continue;
const entry = raw as Record<string, unknown>;
const name = String(entry.name ?? 'Unnamed cluster').replace(/[\x00-\x1F]/g, '').slice(0, 100);
const prNumbers: number[] = Array.isArray(entry.pr_numbers)
? entry.pr_numbers.filter((n): n is number => typeof n === 'number' && n > 0 && n < 1_000_000)
: [];
if (prNumbers.length >= 2 && prNumbers.length <= 6) {
clusters.push({ name, pr_numbers: prNumbers });
}
}
return { clusters };
}
// =============================================================================
// GITHUB CLI HELPERS
// =============================================================================
export function ghExec(args: string[], timeoutMs: number = 30_000): string {
return execFileSync('gh', args, { encoding: 'utf-8', timeout: timeoutMs });
}
export function ghGraphQL(query: string, variables: Record<string, string | number>): unknown {
const args = ['api', 'graphql', '-f', `query=${query}`];
for (const [key, value] of Object.entries(variables)) {
if (typeof value === 'number') {
args.push('-F', `${key}=${value}`);
} else {
args.push('-f', `${key}=${value}`);
}
}
return JSON.parse(execFileSync('gh', args, { encoding: 'utf-8', timeout: 30_000 }));
}
// =============================================================================
// GLOB PATTERN MATCHING
// =============================================================================
export function globToRegex(pattern: string): RegExp {
const escaped = pattern
.replace(/\*\*/g, '<<<GLOBSTAR>>>')
.replace(/\*/g, '[^/]*')
.replace(/<<<GLOBSTAR>>>/g, '.*');
return new RegExp(`^${escaped}`);
}
// =============================================================================
// CODEOWNERS PARSING
// =============================================================================
const CODEOWNERS_PATH = '.github/CODEOWNERS';
function codeownersPatternToRegex(pattern: string): RegExp {
let p = pattern;
if (p.startsWith('/')) p = p.slice(1);
if (p.endsWith('/')) p += '**';
if (!p.includes('/')) p = '**/' + p;
return globToRegex(p);
}
export function parseCodeowners(filePath: string = CODEOWNERS_PATH): CodeownersEntry[] {
if (!existsSync(filePath)) {
log.warning(`CODEOWNERS file not found: ${filePath}`);
return [];
}
const lines = readFileSync(filePath, 'utf-8').split('\n');
const entries: CodeownersEntry[] = [];
for (const raw of lines) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split(/\s+/);
if (parts.length < 2) continue;
const rawPattern = parts[0];
const owners = parts.slice(1).filter((o) => o.startsWith('@'));
if (owners.length === 0) continue;
entries.push({
pattern: codeownersPatternToRegex(rawPattern),
rawPattern,
owners,
});
}
return entries;
}
/**
* In CODEOWNERS the last matching rule wins. Returns the owners for a file
* by scanning all entries and keeping the last match.
*/
export function getFileOwners(file: string, entries: CodeownersEntry[]): string[] {
let owners: string[] = [];
for (const entry of entries) {
if (entry.pattern.test(file)) {
owners = entry.owners;
}
}
return owners;
}
/**
* Checks whether any file in the list is owned (per CODEOWNERS) by one of
* the team's handles. Returns the first matching file and owner for logging.
*/
export function matchFilesToCodeownersTeams(
files: string[],
entries: CodeownersEntry[],
teamHandles: string[],
): { matched: boolean; file: string; owner: string } {
if (!teamHandles.length || !entries.length) {
return { matched: false, file: '', owner: '' };
}
const handleSet = new Set(teamHandles.map((h) => h.toLowerCase()));
for (const file of files) {
const owners = getFileOwners(file, entries);
for (const owner of owners) {
if (handleSet.has(owner.toLowerCase())) {
return { matched: true, file, owner };
}
}
}
return { matched: false, file: '', owner: '' };
}
// =============================================================================
// SLACK HELPERS
// =============================================================================
export async function sendSlackMessage(
token: string,
payload: Record<string, unknown>,
): Promise<boolean> {
try {
const response = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(15_000),
});
const body = (await response.json()) as Record<string, unknown>;
if (response.ok && body.ok === true) {
return true;
}
log.warning(`Slack API error: ${(body.error as string) ?? 'unknown'} (HTTP ${response.status})`);
console.log(JSON.stringify(body, null, 2));
return false;
} catch (err) {
log.error(`Slack request failed: ${err instanceof Error ? err.message : String(err)}`);
return false;
}
}
// =============================================================================
// OPENAI HELPERS
// =============================================================================
interface OpenAIMessage {
role: 'system' | 'user';
content: string;
}
interface OpenAIOptions {
model?: string;
maxTokens?: number;
temperature?: number;
jsonMode?: boolean;
timeoutMs?: number;
}
export async function callOpenAI(
apiKey: string,
messages: OpenAIMessage[],
options: OpenAIOptions = {},
): Promise<string> {
const {
model = 'gpt-4o-mini',
maxTokens = 1500,
temperature = 0.3,
jsonMode = false,
timeoutMs = 30_000,
} = options;
const requestBody: Record<string, unknown> = {
model,
messages,
max_tokens: maxTokens,
temperature,
};
if (jsonMode) {
requestBody.response_format = { type: 'json_object' };
}
console.log(`OpenAI request: model=${model}, jsonMode=${jsonMode}, temperature=${temperature}`);
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
log.warning(`OpenAI API returned ${response.status}`);
return '';
}
const body = (await response.json()) as Record<string, unknown>;
const choices = body.choices as { message?: { content?: string } }[] | undefined;
return choices?.[0]?.message?.content ?? '';
} catch (err) {
log.warning(`OpenAI API call failed: ${err instanceof Error ? err.message : String(err)}`);
return '';
}
}
// =============================================================================
// DISPLAY HELPERS
// =============================================================================
export function issueLink(repo: string, number: number): string {
return `<https://github.com/${repo}/issues/${number}|#${number}>`;
}
export function prLink(repo: string, number: number): string {
return `<https://github.com/${repo}/pull/${number}|#${number}>`;
}
export function buildLinks(
numbers: number[],
linkFn: (num: number) => string,
maxItems: number = 20,
overflowUrl?: string,
): string {
const displayed = numbers.slice(0, maxItems);
const links = displayed.map(linkFn).join(', ');
if (numbers.length > maxItems && overflowUrl) {
return `${links} <${overflowUrl}|View all on GitHub>`;
}
return links;
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}