diff --git a/.github/A3_Submission.txt b/.github/A3_Submission.txt new file mode 100644 index 000000000..3cb4f703c --- /dev/null +++ b/.github/A3_Submission.txt @@ -0,0 +1,15 @@ +A3: CI/CD Pipeline with GitHub Actions +Patrick Augusto — Universitat de Lleida, 2025–2026 + +Workflow | File | Trigger | Jobs +-----------|----------|--------------------------------------|-------------------------------------------- +CI | ci.yml | push, pull_request | Lint → Test → Build +CD | cd.yml | push (main/develop), manual dispatch | Build → Deploy (Hosting, Functions, Firestore, Storage) + +Link | URL +--------------|-------------------------------------------------------------------- +Report | https://github.com/Patrick-Augusto/RUXAILAB/blob/develop/.github/A3_CI_CD_Pipeline_Report.md +CI Workflow | https://github.com/Patrick-Augusto/RUXAILAB/blob/develop/.github/workflows/ci.yml +CD Workflow | https://github.com/Patrick-Augusto/RUXAILAB/blob/develop/.github/workflows/cd.yml +Repository | https://github.com/Patrick-Augusto/RUXAILAB + diff --git a/.github/PRD_Unified_Quality_Pipeline.md b/.github/PRD_Unified_Quality_Pipeline.md new file mode 100644 index 000000000..6d3ba2443 --- /dev/null +++ b/.github/PRD_Unified_Quality_Pipeline.md @@ -0,0 +1,360 @@ +# PRD — Unified Quality & Security Pipeline + +**Status:** Draft (v3) +**Date:** 2026-05-05 +**Owner:** RUXAILAB Engineering + +--- + +## 1. Overview + +This document defines the requirements for consolidating **six** existing GitHub Actions workflows into a **single unified pipeline** (`quality-pipeline.yml`). + +### 1.1 Workflows to be Unified + +| # | Workflow File | Purpose | Current Trigger | +|---|---|---|---| +| 1 | `ComplexiCheckGP.yml` | Cyclomatic complexity — Python files changed in PR | PR only | +| 2 | `conditional-nestingGP.yml` | Depth of conditional nesting — changed `.py` files | Push + PR, all branches | +| 3 | `identifier_lengthGP.yml` | Identifier length metric — changed `.py` files | Push + PR, all branches | +| 4 | `metrics-Gp.yml` | Comment readability (Fog Index) — full source scan | Push to specific branches | +| 5 | `integrity-GP.yml` | Security audit: access control, route auth, OWASP scan, report | Push, PR, schedule | +| 6 | `Metrics-html-reportGP.yml` | Renders the Integrity JSON artifact into an HTML report | `workflow_run` (after Integrity) | + +### 1.2 Scripts (Modified to Add Cross-Language Support) + +| Script | Metric | Languages Supported (v3) | +|---|---|---| +| `CyclomaticMetricGP.py` | Cyclomatic complexity | **Python, JS, TS, JSX, TSX, MJS, CJS** | +| `depth_conditional_nesting_GP.py` | Conditional nesting depth | **Python, JS, TS, JSX, TSX, MJS, CJS** | +| `len_identifiersGP.py` | Identifier average length | **Python, JS, TS, JSX, TSX, MJS, CJS** | +| `fog_comments_indexGP.py` | Comment readability (Fog/Gunning) | Python, JS, TS, Java, C++, HTML *(unchanged)* | +| `route-auth-audit.js` | Vue Router authorization guard | **JavaScript + Python Flask/Django/FastAPI** | +| `generate-integrity-report.js` | Aggregates security findings → JSON | JavaScript *(unchanged)* | +| `render-integrity-html.js` | Renders JSON report → HTML | JavaScript *(unchanged)* | +| `generate_qc_report.js` | Quality control aggregation | JavaScript *(unchanged)* | + +> **What changed in each script:** +> - `CyclomaticMetricGP.py` — Added `calcular_js()` regex-based cyclomatic complexity for JS/TS. Dispatches by file extension. `es_archivo_valido()` now accepts `.js .ts .mjs .tsx .jsx .cjs`. +> - `depth_conditional_nesting_GP.py` — Added `get_metrics_js()` using brace-tracking to estimate conditional nesting depth. `main()` now accepts JS/TS extensions. +> - `len_identifiersGP.py` — Expanded `KEYWORDS` to include Python + JavaScript/TypeScript reserved words. `main()` now accepts JS/TS extensions. +> - `route-auth-audit.js` — Added a second audit section that scans Python `.py` source files for Flask/Django/FastAPI route decorators and reports routes missing auth decorators. + +### 1.3 Target Languages + +The unified pipeline must detect and analyze files across: + +| Language | Extensions | +|---|---| +| Python | `.py` | +| JavaScript | `.js`, `.mjs`, `.cjs` | +| TypeScript | `.ts`, `.tsx` | +| Java | `.java` | +| C# | `.cs` | + +**Goal:** Replace all six workflow files with `quality-pipeline.yml`, executing every metric and security check in a structured, dependency-aware job graph, and producing one consolidated report artifact per run. + +--- + +## 2. Problem Statement + +### 2.1 Current Pain Points + +- **Fragmentation (6 files):** Six independent workflow files with overlapping triggers, duplicated `actions/checkout` and runtime-setup steps, and no shared artifact strategy. +- **Inconsistent language coverage:** Cyclomatic complexity, nesting depth, and identifier length only run on Python files. The pipeline should surface equivalent quality signals across all five target languages. +- **Inconsistent triggers:** `ComplexiCheckGP.yml` only fires on PRs; `metrics-Gp.yml` only on push to specific branches; `conditional-nestingGP.yml` and `identifier_lengthGP.yml` run on all branches but independently. A PR can pass without all checks running. +- **`workflow_run` coupling:** `Metrics-html-reportGP.yml` waits for `Integrity` to complete via `workflow_run`, adding latency and a cross-workflow dependency that breaks if the workflow is renamed. +- **Redundant setup:** Each workflow installs Python 3.x and/or Node.js LTS independently, wasting runner-minutes. +- **No unified report:** Six workflows produce unrelated artifacts (or no artifact at all). There is no single view of quality and security health per PR or push. +- **No gate enforcement:** Complexity, nesting depth, and identifier length produce reports but never fail the pipeline. + +### 2.2 Impact + +- A PR introducing deeply nested C# or Java code passes all checks because the nesting script only runs on `.py`. +- Security issues found by `integrity-GP.yml` can coexist with high-complexity or unreadable code without cross-visibility in a single status check. +- Reviewers must navigate six separate workflow runs to get a complete quality picture. + +--- + +## 3. Goals + +1. **Single workflow file** (`quality-pipeline.yml`) that replaces all six YMLs. +2. **Consistent triggers** — run the full pipeline on every PR and on push to `main`, `develop`, and `features/**`. +3. **Multi-language file detection** — each quality job filters changed files by language-specific extensions at runtime. +4. **Dependency-ordered jobs** — quality metric jobs run in parallel first; security audit jobs run in parallel after; report generation runs last. +5. **Unified artifact** — one JSON + one HTML report per run, aggregating all findings. +6. **Enforce gates** — configurable fail thresholds for cyclomatic complexity, nesting depth, identifier length, and fog index. +7. **Eliminate `workflow_run` dependency** — HTML rendering is the final job in the same workflow, not a separate triggered workflow. + +--- + +## 4. Non-Goals + +- Replacing or modifying `ci.yml` or `cd.yml`. +- Adding new security rules beyond those already in `integrity-GP.yml`. +- Running E2E tests or deployment steps. +- Adding support for C# in the Python metric scripts (C# AST requires Roslyn; out of scope for v1 — tracked in OQ-2). +- Adding a Java AST parser to the Python metric scripts (Java requires a separate parser; Semgrep covers Java for security scans). + +--- + +## 5. Language & File Detection Strategy + +The metric scripts have been updated to support multiple languages. Coverage matrix: + +| Job / Script | Python | JavaScript | TypeScript | Java | C# | +|---|---|---|---|---|---| +| Cyclomatic complexity (`CyclomaticMetricGP.py`) | AST | Regex | Regex | — (OQ-1) | — (OQ-2) | +| Nesting depth (`depth_conditional_nesting_GP.py`) | AST | Brace-tracking | Brace-tracking | — (OQ-1) | — (OQ-2) | +| Identifier length (`len_identifiersGP.py`) | Regex | Regex | Regex | — (OQ-1) | — (OQ-2) | +| Fog Index (`fog_comments_indexGP.py`) | Regex | Regex | Regex | Regex | — (OQ-2) | +| Route auth (`route-auth-audit.js`) | Flask/Django/FastAPI | Vue Router | Vue Router | — | — | +| OWASP scan (Semgrep) | Semgrep | Semgrep | Semgrep | Semgrep | Semgrep (`p/csharp`) | +| Access control audit (bash) | bash grep | bash grep | bash grep | bash grep | bash grep | + +> **AST** = language-native abstract syntax tree (most accurate) +> **Regex** = pattern-based estimation (good enough for CI gates) +> **Brace-tracking** = structural depth via `{` / `}` counting +> `— (OQ-n)` = not supported in v1; tracked as open question + +The pipeline skips a step when no files of a given language are changed, rather than failing. + +--- + +## 6. Proposed Architecture + +### 6.1 Trigger Strategy + +```yaml +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main, develop, "features/**"] + schedule: + - cron: '0 3 * * 1' # weekly integrity sweep (Monday 03:00 UTC) + workflow_dispatch: + +permissions: + contents: read +``` + +### 6.2 Job Graph + +``` +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ cyclomatic- │ │ nesting-depth │ │ identifier- │ │ fog-index- │ +│ complexity │ │ (all languages) │ │ length │ │ metrics │ +│ (Python) │ │ │ │ (Python) │ │ (JS/TS/Java/Py) │ +└────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ + └───────────────────┬┴──────────────────────┘ │ + │ (all 4 quality jobs complete) │ + └────────────────────┬──────────────────────┘ + ┌─────────────────────┬─┴──────────────────────┐ + ▼ ▼ ▼ + ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────┐ + │ access-control │ │ route-auth │ │ static-security │ + │ audit │ │ audit │ │ scan (OWASP/Semgrep)│ + └────────┬─────────┘ └────────┬─────────┘ └──────────┬───────────┘ + └───────────────────┬─┴─────────────────────┘ + ▼ + ┌──────────────────────┐ + │ generate-report │ + │ (JSON + HTML) │ + │ if: always() │ + └──────────────────────┘ +``` + +### 6.3 Job Descriptions + +#### Job 1 — `cyclomatic-complexity` +- **Source:** `ComplexiCheckGP.yml` +- **Runtime:** Ubuntu Latest, Python 3.12 +- **File detection:** Changed `.py` files on PR (via `git diff`); all `.py` files on push +- **Steps:** + 1. Checkout (`fetch-depth: 0`) + 2. Setup Python 3.12 + 3. Detect target files by extension + 4. Run `CyclomaticMetricGP.py ` → `complexity-results.json` + 5. **Gate:** Exit non-zero if `summary.failed_files > 0` (threshold: 15 per script default) +- **Artifact:** `quality-job1/complexity-results.json` + +#### Job 2 — `nesting-depth` +- **Source:** `conditional-nestingGP.yml` +- **Runtime:** Ubuntu Latest, Python 3.x +- **File detection:** Changed `.py` files (via `tj-actions/changed-files`) +- **Steps:** + 1. Checkout (`fetch-depth: 0`) + 2. Setup Python + 3. Detect changed `.py` files + 4. Run `depth_conditional_nesting_GP.py ` → `nesting_metrics.json` + 5. **Gate:** Script exits 1 if any file exceeds `CRITICAL_THRESHOLD` (5) +- **Artifact:** `quality-job2/nesting_metrics.json` + +#### Job 3 — `identifier-length` +- **Source:** `identifier_lengthGP.yml` +- **Runtime:** Ubuntu Latest, Python 3.x +- **File detection:** Changed `*.py` files (via `tj-actions/changed-files`) +- **Steps:** + 1. Checkout (`fetch-depth: 0`) + 2. Setup Python + 3. Detect changed `*.py` files + 4. Run `len_identifiersGP.py ` → `identifier_report.json` + 5. **Gate:** Fail if `summary.failed_files > 0` (avg identifier length < 10) +- **Artifact:** `quality-job3/identifier_report.json` + +#### Job 4 — `fog-index-metrics` +- **Source:** `metrics-Gp.yml` +- **Runtime:** Ubuntu Latest, Python 3.12 +- **File detection:** Scans entire source tree; `fog_comments_indexGP.py` already handles `.py`, `.js`, `.ts`, `.java`, `.html` natively +- **Steps:** + 1. Checkout + 2. Setup Python 3.12 + install `textstat` + 3. Run `fog_comments_indexGP.py .` → `fog-results.json` + 4. **Gate:** Fail if `summary.failed_files > 0` (fog score > 17 per script default) +- **Artifact:** `quality-job4/fog-results.json` + +#### Job 5 — `access-control-audit` +- **Source:** `integrity-GP.yml` Job 1 +- **Needs:** `cyclomatic-complexity`, `nesting-depth`, `identifier-length`, `fog-index-metrics` +- **Runtime:** Ubuntu Latest (bash only) +- **Languages covered:** Express/Fastify (JS/TS), Django/Flask (Python), Spring Boot (Java), SQL, Docker/K8s, generic REST +- **Artifact:** `quality-job5/access-control.log` + +#### Job 6 — `route-auth-audit` +- **Source:** `integrity-GP.yml` Job 2 +- **Needs:** all 4 quality jobs +- **Runtime:** Ubuntu Latest, Node.js LTS +- **Steps:** Vue Router guard integrity check + `route-auth-audit.js` +- **Artifact:** `quality-job6/route-auth.log` + +#### Job 7 — `static-security-scan` +- **Source:** `integrity-GP.yml` Job 3 +- **Needs:** all 4 quality jobs +- **Runtime:** Ubuntu Latest, Python (Semgrep) +- **Languages scanned:** JavaScript, TypeScript, Python, Java (Semgrep multi-language support) +- **Note for C#:** Semgrep supports C# via `--config=p/csharp`; add `--include="*.cs"` to the scan command +- **Steps:** OWASP Top 10 Semgrep scan + auth bypass pattern checks +- **Artifact:** `quality-job7/static-scan.log` + +#### Job 8 — `generate-report` +- **Source:** `integrity-GP.yml` Job 4 + `Metrics-html-reportGP.yml` (merged) +- **Needs:** all 7 jobs above +- **Condition:** `if: always()` +- **Runtime:** Ubuntu Latest, Node.js LTS +- **Steps:** + 1. Download all 7 artifacts + 2. Run `generate-integrity-report.js` (to be extended to consume quality metric JSONs) + 3. Run `render-integrity-html.js` to produce the HTML report + 4. Upload consolidated `quality-security-report` (JSON + HTML) +- **Artifact:** `quality-security-report/report.json` + `quality-security-report/report.html` +- **Retention:** 30 days + +--- + +## 7. Acceptance Criteria + +| # | Criterion | +|---|---| +| AC-1 | A single file `quality-pipeline.yml` exists and replaces all 6 source workflow files | +| AC-2 | All 8 jobs execute when a PR is opened or updated | +| AC-3 | All 8 jobs execute on push to `main`, `develop`, or `features/**` | +| AC-4 | Jobs 5, 6, 7 only start after all 4 quality jobs complete (regardless of pass/fail) | +| AC-5 | Job 8 (`generate-report`) always runs and downloads all 7 preceding artifacts | +| AC-6 | The artifact `quality-security-report` (JSON + HTML) is uploaded and visible in Actions UI | +| AC-7 | A `.py` file with cyclomatic complexity above the script threshold causes Job 1 to fail | +| AC-8 | A `.py` file with nesting depth > 5 causes Job 2 to fail | +| AC-9 | A file with average identifier length < 10 causes Job 3 to fail | +| AC-10 | A file with fog index > 17 causes Job 4 to fail | +| AC-11 | Semgrep scans `.js`, `.ts`, `.java`, `.py`, and `.cs` files in `src/` and `functions/src/` | +| AC-12 | The pipeline runs without error when no files of a given language are changed (graceful skip) | +| AC-13 | All existing integrity checks from `integrity-GP.yml` remain functionally unchanged | +| AC-14 | The six source workflow files are deleted after `quality-pipeline.yml` is verified green on a real PR | + +--- + +## 8. Metric Thresholds & Gates + +| Job | Metric | Script Default | Gate Behavior | +|---|---|---|---| +| cyclomatic-complexity | Max complexity per file | 15 (`DANGER` > 15) | Fail if `failed_files > 0` | +| nesting-depth | Max nesting depth per file | 5 (`CRITICAL`) | Fail if `failed_files > 0` | +| identifier-length | Avg identifier length per file | < 10 = `IMPROVE` | Fail if `failed_files > 0` | +| fog-index-metrics | Avg fog score per file | > 17 = `COMPLEX` | Fail if `failed_files > 0` | + +All thresholds are owned by the individual scripts. The pipeline does not re-define them; it reads `summary.failed_files` from each script's JSON output to decide pass/fail. + +--- + +## 9. Consolidated Report Schema (JSON) + +```json +{ + "run_id": "", + "ref": "", + "sha": "", + "timestamp": "", + "languages_targeted": ["python", "javascript", "typescript", "java", "csharp"], + "summary": { + "overall": "pass | warn | fail", + "jobs": { + "cyclomatic-complexity": "pass | fail | skipped", + "nesting-depth": "pass | fail | skipped", + "identifier-length": "pass | fail | skipped", + "fog-index-metrics": "pass | fail | skipped", + "access-control-audit": "pass | fail", + "route-auth-audit": "pass | fail", + "static-security-scan": "pass | fail" + } + }, + "quality": { + "complexity": { /* CyclomaticMetricGP.py output */ }, + "nesting": { /* depth_conditional_nesting_GP.py output */ }, + "identifier_length": { /* len_identifiersGP.py output */ }, + "fog_index": { /* fog_comments_indexGP.py output */ } + }, + "security": { + "access_control": { /* parsed access-control.log */ }, + "route_auth": { /* parsed route-auth.log */ }, + "static_scan": { /* parsed static-scan.log */ } + } +} +``` + +--- + +## 10. Migration Plan + +| Step | Action | Owner | +|---|---|---| +| 1 | Create `quality-pipeline.yml` implementing all 8 jobs | Engineer | +| 2 | Extend `generate-integrity-report.js` to consume quality metric JSONs (Jobs 1-4) | Engineer | +| 3 | Validate pipeline on a test branch with files in each target language | Engineer | +| 4 | Run old and new pipelines in parallel on 2 real PRs to confirm result parity | Reviewer | +| 5 | Delete `ComplexiCheckGP.yml`, `conditional-nestingGP.yml`, `identifier_lengthGP.yml`, `metrics-Gp.yml`, `integrity-GP.yml`, `Metrics-html-reportGP.yml` | Engineer | +| 6 | Update `.github/README_CheckSheet` and any CI documentation | Engineer | + +--- + +## 11. Open Questions + +| # | Question | Decision | +|---|---| +---| +| OQ-1 | Should cyclomatic complexity, nesting depth, and identifier length be extended to Java? (Requires a Java parser — e.g. `javalang` Python lib) | TBD — v1 is Python/JS/TS only for these metrics | +| OQ-2 | Should C# be added to the metric scripts and Fog Index? (Requires Roslyn or regex for `.cs`) | TBD | +| OQ-3 | Should thresholds be extracted to a shared `.quality-config.yml` file instead of relying on script defaults? | TBD | +| OQ-4 | Should the schedule trigger (Monday 03:00 UTC) be kept in the unified pipeline? | TBD | +| OQ-5 | Should `generate-integrity-report.js` be renamed to `generate-quality-report.js` to reflect broader scope? | TBD | +| OQ-6 | Should Job 8 post a PR comment summarizing the report (using `generate_pr_summary.js`)? | TBD — out of scope for v1 | + +--- + +## 12. Out of Scope for v1 + +- Trend tracking (storing historical metric data across runs) +- PR comment bot posting a quality summary +- Per-author attribution of complexity or nesting violations +- Blocking merges via branch protection rules (configured separately in GitHub Settings) +- Java AST-based metric scripts (complexity, nesting, identifier length for `.java`) +- C# support in metric scripts and Fog Index diff --git a/.github/scripts/create-monthly-milestone.js b/.github/scripts/create-monthly-milestone.js new file mode 100644 index 000000000..2949e6493 --- /dev/null +++ b/.github/scripts/create-monthly-milestone.js @@ -0,0 +1,549 @@ +// .github/scripts/create-monthly-milestone.js +// ============================================================= +// Bot-driven monthly milestone creator with Discord notification. +// Designed for actions/github-script@v7 — receives { github, context, core }. +// +// Redundancy layers: +// 1. YAML parsing: npm "yaml" package → line-by-line fallback → hardcoded defaults +// 2. Milestone creation: POST create → on 422, GET + PATCH update (idempotent) +// 3. Discord webhook: retry 3× with exponential backoff → warn on final failure +// ============================================================= + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const http = require('http'); + +// ── Hardcoded fallback objectives (last resort) ───────────────────────────── +const HARDCODED_OBJECTIVES = [ + 'Review and triage open issues', + 'Merge approved pull requests', + 'Update project documentation', + 'Improve test coverage', +]; + +// ── Month utilities ───────────────────────────────────────────────────────── +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +/** + * Resolve the target month index (0-based) and year from workflow_dispatch + * inputs, falling back to the current UTC date. + */ +function resolveMonthYear(core) { + const inputMonth = (process.env.INPUT_MONTH || '').trim(); + const inputYear = (process.env.INPUT_YEAR || '').trim(); + + const now = new Date(); + let month = now.getUTCMonth(); // 0-based + let year = now.getUTCFullYear(); + + if (inputMonth) { + const idx = MONTH_NAMES.findIndex( + (m) => m.toLowerCase() === inputMonth.toLowerCase() + ); + if (idx !== -1) { + month = idx; + } else { + core.warning( + `Invalid month input "${inputMonth}". Falling back to current month (${MONTH_NAMES[month]}).` + ); + } + } + + if (inputYear) { + const parsed = parseInt(inputYear, 10); + if (!isNaN(parsed) && parsed >= 2020 && parsed <= 2100) { + year = parsed; + } else { + core.warning( + `Invalid year input "${inputYear}". Falling back to current year (${year}).` + ); + } + } + + return { month, year }; +} + +/** + * Returns the last day of a given month (1-indexed day). + */ +function lastDayOfMonth(year, month) { + // month is 0-based; Date with day 0 of next month = last day of this month + return new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); +} + +// ── YAML parsing with fallback ────────────────────────────────────────────── + +/** + * Try to load the "yaml" npm package. Returns null if unavailable. + */ +function tryRequireYaml(core) { + try { + return require('yaml'); + } catch { + core.warning( + 'Could not load "yaml" npm package. Using built-in fallback parser.' + ); + return null; + } +} + +/** + * Permissive line-by-line parser for our specific milestone-config.yml structure. + * Handles only the flat structure we expect — NOT a general YAML parser. + * + * Expected structure: + * default_objectives: + * - "text" + * milestones: + * "Key Name": + * - "text" + */ +function fallbackParseYaml(content, core) { + const result = { default_objectives: [], milestones: {} }; + + const lines = content.split('\n'); + let currentSection = null; // 'default_objectives' | 'milestones' + let currentMilestoneKey = null; + + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, ''); + + // Skip empty lines and comments + if (/^\s*$/.test(line) || /^\s*#/.test(line)) continue; + + // Top-level keys (no indentation) + if (/^default_objectives:\s*$/.test(line)) { + currentSection = 'default_objectives'; + currentMilestoneKey = null; + continue; + } + if (/^milestones:\s*$/.test(line)) { + currentSection = 'milestones'; + currentMilestoneKey = null; + continue; + } + + // Milestone sub-key (indented key): e.g. "June 2026": or 'June 2026': or June 2026: + if (currentSection === 'milestones') { + const keyMatch = line.match(/^\s+["']?([^"']+)["']?:\s*$/); + if (keyMatch) { + currentMilestoneKey = keyMatch[1].trim(); + result.milestones[currentMilestoneKey] = []; + continue; + } + } + + // List items starting with "-" + const itemMatch = line.match(/^\s*-\s+["']?([^"']+)["']?\s*$/); + if (itemMatch) { + const value = itemMatch[1].trim(); + if (currentSection === 'default_objectives' && !currentMilestoneKey) { + result.default_objectives.push(value); + } else if ( + currentSection === 'milestones' && + currentMilestoneKey && + result.milestones[currentMilestoneKey] + ) { + result.milestones[currentMilestoneKey].push(value); + } + } + } + + core.info( + `Fallback parser: found ${result.default_objectives.length} default objectives, ` + + `${Object.keys(result.milestones).length} milestone entries.` + ); + + return result; +} + +/** + * Type-check a parsed config object. Returns { default_objectives, milestones } + * with guaranteed shapes: + * - default_objectives: string[] (non-empty strings) OR null if invalid + * - milestones: { [title: string]: string[] } (malformed entries dropped, with warnings) + */ +function normalizeParsedConfig(parsed, core) { + const isNonEmptyString = (v) => typeof v === 'string' && v.trim().length > 0; + const isStringArray = (v) => + Array.isArray(v) && v.length > 0 && v.every(isNonEmptyString); + + let default_objectives = null; + if (parsed && 'default_objectives' in parsed) { + if (isStringArray(parsed.default_objectives)) { + default_objectives = parsed.default_objectives; + } else { + core.warning( + `Config "default_objectives" is malformed (expected non-empty array of strings, got ${typeof parsed.default_objectives}). Will use hardcoded defaults.` + ); + } + } + + const milestones = {}; + if (parsed && parsed.milestones && typeof parsed.milestones === 'object' && !Array.isArray(parsed.milestones)) { + for (const [key, value] of Object.entries(parsed.milestones)) { + if (!isNonEmptyString(key)) { + core.warning(`Dropping milestone entry with non-string key.`); + continue; + } + if (isStringArray(value)) { + milestones[key] = value; + } else { + core.warning( + `Dropping milestone "${key}" (expected non-empty array of strings, got ${typeof value}).` + ); + } + } + } else if (parsed && parsed.milestones !== undefined) { + core.warning( + `Config "milestones" is malformed (expected object, got ${Array.isArray(parsed.milestones) ? 'array' : typeof parsed.milestones}). Treating as empty.` + ); + } + + return { default_objectives, milestones }; +} + +/** + * Read and parse milestone-config.yml with layered fallbacks. + */ +function loadConfig(core) { + const configPath = path.join(process.cwd(), 'milestone-config.yml'); + + // Layer 1: try to read the file + let rawContent; + try { + rawContent = fs.readFileSync(configPath, 'utf8'); + core.info(`Loaded config from ${configPath}`); + } catch (err) { + core.warning( + `Could not read milestone-config.yml (${err.message}). Using hardcoded defaults.` + ); + return { default_objectives: HARDCODED_OBJECTIVES, milestones: {} }; + } + + // Layer 2: try npm yaml package + const yamlLib = tryRequireYaml(core); + if (yamlLib) { + try { + const parsed = yamlLib.parse(rawContent); + if (parsed && typeof parsed === 'object') { + core.info('Config parsed successfully with yaml library.'); + const normalized = normalizeParsedConfig(parsed, core); + return { + default_objectives: normalized.default_objectives || HARDCODED_OBJECTIVES, + milestones: normalized.milestones, + }; + } + } catch (err) { + core.warning( + `yaml library parse error: ${err.message}. Trying fallback parser.` + ); + } + } + + // Layer 3: fallback line-by-line parser + try { + const parsed = fallbackParseYaml(rawContent, core); + return { + default_objectives: + parsed.default_objectives.length > 0 + ? parsed.default_objectives + : HARDCODED_OBJECTIVES, + milestones: parsed.milestones || {}, + }; + } catch (err) { + core.warning( + `Fallback parser failed: ${err.message}. Using hardcoded defaults.` + ); + return { default_objectives: HARDCODED_OBJECTIVES, milestones: {} }; + } +} + +// ── Discord webhook with retry ────────────────────────────────────────────── + +/** + * Send a JSON payload to a URL via POST. Returns a Promise. + */ +function httpPost(url, payload, timeoutMs = 10000) { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const transport = urlObj.protocol === 'https:' ? https : http; + const data = JSON.stringify(payload); + + const req = transport.request( + { + hostname: urlObj.hostname, + port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), + path: urlObj.pathname + urlObj.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data), + }, + timeout: timeoutMs, + }, + (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body })); + } + ); + + req.on('timeout', () => { + req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)); + }); + req.on('error', (err) => reject(err)); + req.write(data); + req.end(); + }); +} + +/** + * Sleep for the given number of milliseconds. + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Post a Discord webhook embed with up to 3 retries and exponential backoff. + */ +async function postToDiscord(webhookUrl, embed, core) { + const payload = { embeds: [embed] }; + const maxRetries = 3; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + core.info(`Discord webhook attempt ${attempt}/${maxRetries}...`); + const res = await httpPost(webhookUrl, payload); + + // Discord returns 204 No Content on success + if (res.status >= 200 && res.status < 300) { + core.info(`Discord notification sent successfully (HTTP ${res.status}).`); + return true; + } + + // Rate limited — respect Retry-After header if available + if (res.status === 429) { + let retryAfter = 2000 * attempt; // fallback backoff + const headerValue = res.headers && (res.headers['retry-after'] || res.headers['x-ratelimit-reset-after']); + if (headerValue) { + const parsed = parseFloat(headerValue); + if (!isNaN(parsed)) { + // Discord rate limit headers are in seconds, let's convert to ms and add buffer + retryAfter = Math.ceil(parsed * 1000) + 500; + } + } + core.warning( + `Discord rate limited (429). Retrying in ${retryAfter}ms...` + ); + await sleep(retryAfter); + continue; + } + + // Server error — retry + if (res.status >= 500) { + core.warning( + `Discord server error (${res.status}). Retrying in ${1000 * attempt}ms...` + ); + await sleep(1000 * attempt); + continue; + } + + // Client error (4xx other than 429) — do not retry + core.warning( + `Discord webhook failed with HTTP ${res.status}: ${res.body}` + ); + return false; + } catch (err) { + core.warning( + `Discord webhook network error on attempt ${attempt}: ${err.message}` + ); + if (attempt < maxRetries) { + await sleep(1000 * attempt); + } + } + } + + core.warning('Discord webhook: all retries exhausted. Skipping notification.'); + return false; +} + +// ── Main entry point ──────────────────────────────────────────────────────── + +module.exports = async ({ github, context, core }) => { + // 1. Resolve target month & year + const { month, year } = resolveMonthYear(core); + const monthName = MONTH_NAMES[month]; + const milestoneTitle = `${monthName} ${year}`; + const lastDay = lastDayOfMonth(year, month); + // Set predictable time component matching GitHub's default stored value (08:00:00Z) + const dueDate = `${year}-${String(month + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}T08:00:00Z`; + + core.info(`Target milestone: "${milestoneTitle}" (due ${dueDate})`); + + // 2. Load config & resolve objectives + const config = loadConfig(core); + let objectives = + config.milestones[milestoneTitle] || + config.default_objectives || + HARDCODED_OBJECTIVES; + + if ( + !Array.isArray(objectives) || + objectives.length === 0 || + !objectives.every((o) => typeof o === 'string' && o.trim().length > 0) + ) { + core.warning( + 'Objectives malformed (expected a non-empty array of non-empty strings). Falling back to hardcoded defaults.' + ); + objectives = HARDCODED_OBJECTIVES; + } + + core.info(`Objectives (${objectives.length}): ${objectives.join(', ')}`); + + // 3. Build milestone description (Markdown) + const description = [ + `## 🎯 Objectives for ${milestoneTitle}`, + '', + ...objectives.map((obj) => `- [ ] ${obj}`), + '', + '---', + `_This milestone was automatically created by the Monthly Milestone Bot on ${new Date().toISOString().split('T')[0]}._`, + ].join('\n'); + + // 4. Create or update GitHub Milestone + const owner = context.repo.owner; + const repo = context.repo.repo; + let milestoneNumber; + let milestoneUrl; + let wasUpdated = false; + + try { + // Try to create + const { data: created } = await github.rest.issues.createMilestone({ + owner, + repo, + title: milestoneTitle, + description, + due_on: dueDate, + state: 'open', + }); + milestoneNumber = created.number; + milestoneUrl = created.html_url; + core.info(`✅ Milestone #${milestoneNumber} created: ${milestoneUrl}`); + } catch (createErr) { + // Inspect if the error is specifically a duplicate/already_exists error + const isAlreadyExists = + createErr.status === 422 && + (createErr.response?.data?.errors?.some(e => e.code === 'already_exists') || + (createErr.message && createErr.message.includes('already_exists'))); + + if (isAlreadyExists) { + core.info( + `Milestone "${milestoneTitle}" already exists. Searching to update...` + ); + + // Paginate all pages of milestones to reliably find the existing one + const existingMilestones = await github.paginate( + github.rest.issues.listMilestones, + { + owner, + repo, + state: 'all', + per_page: 100, + } + ); + + const existing = existingMilestones.find( + (m) => m.title === milestoneTitle + ); + + if (existing) { + const { data: updated } = await github.rest.issues.updateMilestone({ + owner, + repo, + milestone_number: existing.number, + description, + due_on: dueDate, + state: 'open', + }); + milestoneNumber = updated.number; + milestoneUrl = updated.html_url; + wasUpdated = true; + core.info( + `✅ Milestone #${milestoneNumber} updated: ${milestoneUrl}` + ); + } else { + core.setFailed( + `GitHub returned 422 (already_exists) but could not find milestone "${milestoneTitle}" during pagination.` + ); + return; + } + } else { + core.setFailed( + `Failed to create milestone (HTTP ${createErr.status ?? createErr.response?.status ?? 'unknown'}): ${createErr.message || createErr}` + ); + return; + } + } + + // 5. Discord notification + const webhookUrl = process.env.DISCORD_WEBHOOK_URL; + + if (!webhookUrl) { + core.warning( + 'DISCORD_WEBHOOK_URL secret is not set. Skipping Discord notification.' + ); + core.info('Done. Milestone created/updated successfully without Discord notification.'); + return; + } + + let parsedWebhookUrl; + try { + parsedWebhookUrl = new URL(webhookUrl); + } catch { + core.warning( + 'DISCORD_WEBHOOK_URL is not a valid URL. Skipping Discord notification.' + ); + return; + } + if (parsedWebhookUrl.protocol !== 'https:') { + core.warning( + `DISCORD_WEBHOOK_URL must use https: (got "${parsedWebhookUrl.protocol}"). Refusing to send webhook over plaintext. Skipping notification.` + ); + return; + } + + const actionVerb = wasUpdated ? 'Updated' : 'Created'; + const embedColor = wasUpdated ? 0xffa500 : 0x2ecc71; // orange for update, green for create + + const embed = { + title: `📅 ${milestoneTitle} — Monthly Milestone ${actionVerb}`, + description: [ + wasUpdated + ? `The milestone for **${milestoneTitle}** has been updated with the latest objectives.` + : `A new milestone has been created for **${milestoneTitle}**.`, + '', + '**🎯 Objectives:**', + ...objectives.map((obj) => `• ${obj}`), + '', + `**📆 Due Date:** ${lastDay}/${String(month + 1).padStart(2, '0')}/${year}`, + '', + `🔗 [View Milestone on GitHub](${milestoneUrl})`, + ].join('\n'), + color: embedColor, + footer: { + text: `RUXAILAB • Monthly Milestone Bot`, + }, + timestamp: new Date().toISOString(), + }; + + await postToDiscord(webhookUrl, embed, core); + + core.info(`🎉 Done! Milestone "${milestoneTitle}" ${actionVerb.toLowerCase()} and Discord notified.`); +}; diff --git a/.github/workflows/monthly-milestone.yml b/.github/workflows/monthly-milestone.yml new file mode 100644 index 000000000..62292f8e8 --- /dev/null +++ b/.github/workflows/monthly-milestone.yml @@ -0,0 +1,56 @@ +name: Monthly Milestone Creator + +on: + schedule: + # Runs at 08:00 UTC on the 1st of each month + # (Avoids midnight to reduce GitHub Actions cron scheduling delays) + - cron: '0 8 1 * *' + + workflow_dispatch: + inputs: + month: + description: 'Month name in English (e.g. June). Leave empty for current month.' + required: false + type: string + year: + description: 'Year (e.g. 2026). Leave empty for current year.' + required: false + type: string + +permissions: + contents: read # To checkout repo and read milestone-config.yml + issues: write # To create/update milestones (milestones use the Issues API) + +jobs: + create-milestone: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + # Pin Node explicitly — ubuntu-latest's bundled Node version is not a + # stable contract and can shift between runner image releases. + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install YAML parser + # Installed with --no-save to avoid polluting package.json / package-lock.json. + # Version is pinned (~2.6.1 = patches only) so a new yaml release on the + # scheduled cron run cannot silently break parsing. Bump intentionally. + # The script has a fallback parser if this step fails. + run: npm install --no-save yaml@~2.6.1 + continue-on-error: true + + - name: Create or Update Monthly Milestone + uses: actions/github-script@v7 + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + INPUT_MONTH: ${{ github.event.inputs.month || '' }} + INPUT_YEAR: ${{ github.event.inputs.year || '' }} + with: + script: | + const script = require('./.github/scripts/create-monthly-milestone.js'); + await script({ github, context, core }); diff --git a/identifier_report.json b/identifier_report.json new file mode 100644 index 000000000..b388fcad2 --- /dev/null +++ b/identifier_report.json @@ -0,0 +1,20 @@ +{ + "analysis_type": "Identifier Length", + "threshold": 6, + "summary": { + "total_files": 2, + "failed_files": 0 + }, + "results": [ + { + "file": "src/ux/accessibility/store/Assessment.js", + "avg_length": 8.09, + "status_code": "OK!" + }, + { + "file": "src/shared/composables/useItemFormatting.js", + "avg_length": 6.62, + "status_code": "OK!" + } + ] +} \ No newline at end of file diff --git a/milestone-config.yml b/milestone-config.yml new file mode 100644 index 000000000..5f59eeba2 --- /dev/null +++ b/milestone-config.yml @@ -0,0 +1,27 @@ +# milestone-config.yml — Monthly Milestone Bot Configuration +# ============================================================ +# Maintainers: edit the objectives for the upcoming month BEFORE the 1st. +# If no entry matches the current month, "default_objectives" will be used. +# +# Keys under "milestones" must use the format: "Month YYYY" +# e.g. "June 2026", "July 2026", "January 2027" +# +# Each entry is a list of objective strings. + +default_objectives: + - "Review and triage open issues" + - "Merge approved pull requests" + - "Update project documentation" + - "Improve test coverage" + +milestones: + "June 2026": + - "Implement milestone bot automation" + - "Refactor authentication module" + - "Add end-to-end tests for new heuristic flows" + - "Release v0.2.0-beta" + + # "July 2026": + # - "Migrate state management to Pinia" + # - "Performance audit and optimization" + # - "Localization updates (pt-BR, en)" diff --git a/nesting_metrics.json b/nesting_metrics.json new file mode 100644 index 000000000..358a7f5da --- /dev/null +++ b/nesting_metrics.json @@ -0,0 +1,28 @@ +{ + "analysis_type": "Depth of Conditional Nesting", + "threshold": 7, + "summary": { + "total_files": 3, + "failed_files": 0 + }, + "results": [ + { + "file": "src/ux/accessibility/store/Assessment.js", + "avg_depth": 3.3, + "max_depth": 6, + "status_code": "🚨 WARNING" + }, + { + "file": "src/shared/store/Answer.js", + "avg_depth": 4.9, + "max_depth": 7, + "status_code": "🚨 WARNING" + }, + { + "file": "src/features/auth/controllers/UserController.js", + "avg_depth": 3.9, + "max_depth": 6, + "status_code": "🚨 WARNING" + } + ] +} \ No newline at end of file