diff --git a/.ai/decisions.yaml b/.ai/decisions.yaml index aa5ae318..3867ad60 100644 --- a/.ai/decisions.yaml +++ b/.ai/decisions.yaml @@ -54,7 +54,6 @@ decisions: - Memory usage references: - src/services/componentCacheManager.ts - - CACHE_MANAGEMENT.md title: Cache Components USE_SECRETSTORAGE_FOR_TOKENS: date: '2024' @@ -367,7 +366,6 @@ decisions: - More complex implementation references: - src/services/componentService.ts - - PERFORMANCE_OPTIMIZATIONS.md title: Batch Api Requests MOCHA_OVER_JEST: date: '2024' diff --git a/.ai/workflows.yaml b/.ai/workflows.yaml index b4adb4f9..e9285c21 100644 --- a/.ai/workflows.yaml +++ b/.ai/workflows.yaml @@ -81,21 +81,20 @@ workflows: command: vsce package output: gitlab-component-helper-{version}.vsix release: - description: Create a new release + description: Cut and publish a release (two stages; NOT semantic-release) steps: - - action: commit_changes - command: 'git add . && git commit -m ''feat: description''' - note: Use conventional commits (feat, fix, chore, etc.) - - action: push_to_main - command: git push origin main - - action: automatic_release - note: semantic-release runs automatically on push to main + - action: land_changes + command: Merge PRs to beta (pre-release) or main (stable) via the normal PR flow + note: Use conventional commits so release-it can compute the next version + - action: automatic_versioning + note: 'On push to beta/main, ci.yml release job runs release-it (--config .release-it.beta.json or .release-it.json --ci)' creates: - - GitHub release - Version bump in package.json - Updated CHANGELOG.md - - VSIX artifact - reference: SEMANTIC_RELEASE.md + - 'chore(release): [skip ci] commit and git tag' + - action: manual_publish + note: 'Manual workflow_dispatch: Actions -> Publish (main) or Publish Beta (beta), enter version matching package.json; guardrails enforce branch, version match, release-commit HEAD, tests, npm audit, gitleaks, and even(stable)/odd(pre-release) minor before vsce publish' + reference: RELEASING.md add_configuration_option: description: Add new user configuration setting steps: @@ -179,25 +178,4 @@ git_workflow: - Runs commitlint to validate message format purpose: Enforce conventional commit message format format: 'type(scope): description' -syntax_highlighting: - language_definition: - location: syntaxes/gitlab-ci.tmLanguage.json - language_id: gitlab-ci - scope: source.yaml.gitlab-ci - file_extensions: - - .gitlab-ci.yml - - .gitlab-ci.yaml - configuration: - location: language-configuration.json - features: - - Comment toggling - - Bracket matching - - Auto-closing pairs - - Folding markers - registration: - file: package.json - section: contributes.languages - grammar_path: syntaxes/gitlab-ci.tmLanguage.json - embedded_languages: - yaml: For base YAML syntax version: '2.0' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce252ab4..7d125790 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,6 @@ jobs: path: | tests/ out/ - compile.txt retention-days: 30 extension-host: diff --git a/.gitignore b/.gitignore index 10a0ee26..39337f64 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ out-test junit.xml # Other +compile.txt offline-search-index.* .gitlab-ci-local* *.vsix diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 67d62001..a149c6fe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,9 +9,11 @@ repos: rev: v6.0.0 hooks: - id: trailing-whitespace - exclude: ^(docs) + # tests/fixtures/** are intentionally-crafted parser inputs (trailing + # whitespace / blank lines are part of the test scenario) — never rewrite them. + exclude: ^(docs|tests/fixtures) - id: end-of-file-fixer - exclude: ^(docs) + exclude: ^(docs|tests/fixtures) - id: check-ast exclude: ^(docs) # - id: check-yaml @@ -24,7 +26,7 @@ repos: - id: detect-private-key - id: mixed-line-ending args: ["--fix=lf"] - exclude: \.md$ + exclude: (\.md$|^tests/fixtures/) # - id: no-commit-to-branch # args: [--branch, main] - id: pretty-format-json diff --git a/.vscodeignore b/.vscodeignore index bd68adc5..281f9ef6 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -35,7 +35,6 @@ commitlint.config.js .ai/** docs/** AGENTS.md -CACHE_MANAGEMENT.md CLAUDE.md CONTRIBUTING.md DEVELOPERS.md @@ -50,7 +49,6 @@ sync-changes.sh # Test and coverage .nyc_output coverage -compile.txt # Build artifacts **/*.backup diff --git a/.yamllint.yml b/.yamllint.yml index 89e1f44f..3d2b4ad7 100644 --- a/.yamllint.yml +++ b/.yamllint.yml @@ -9,6 +9,12 @@ yaml-files: - ".yamllint" ignore: + # Test fixtures are intentionally-crafted GitLab CI parser inputs: flow-style + # braces ({ job_name: deploy }), empty values (region:), trailing whitespace + # and blank lines are the exact conditions the parser/completion tests exist to + # exercise. They represent arbitrary user files, so they must not be + # style-linted — the mocha suite validates their behaviour instead. + - "tests/fixtures/**" - "node_modules/**" - ".git/**" - ".github/**" diff --git a/AGENTS.md b/AGENTS.md index e0f2a1c2..18dde377 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ This project uses **AI Context as Code (AICaC)** - structured YAML documentation - TypeScript (strict mode) - esbuild (bundler) - Mocha (testing) -- semantic-release (automated releases) +- release-it (version + changelog automation; Marketplace publish is a manual workflow dispatch) ### Entry Points - Extension activation: [`src/extension.ts`](src/extension.ts) @@ -90,7 +90,7 @@ gitlab-component-helper/ **Branch naming:** `feature/description` or `fix/description` **Commit format:** `type(scope): description` -**Release:** Automated via semantic-release on push to main +**Release:** release-it bumps the version + CHANGELOG on push to `beta`/`main` (ci.yml `release` job); publishing to the Marketplace is a manual `workflow_dispatch` (Publish / Publish Beta). See [`docs/RELEASING.md`](docs/RELEASING.md) ### Commit Types - `feat:` New feature (triggers minor version bump) @@ -143,9 +143,8 @@ API requests are batched (default 5 concurrent) to respect rate limits. - **Full specification:** See [`.ai/`](.ai/) directory - **User documentation:** See [`README.md`](README.md) -- **Semantic release:** See [`SEMANTIC_RELEASE.md`](SEMANTIC_RELEASE.md) -- **Cache management:** See [`CACHE_MANAGEMENT.md`](CACHE_MANAGEMENT.md) -- **Performance:** See [`PERFORMANCE_OPTIMIZATIONS.md`](PERFORMANCE_OPTIMIZATIONS.md) +- **Releasing:** See [`docs/RELEASING.md`](docs/RELEASING.md) +- **Cache & performance architecture:** See [`.ai/architecture.yaml`](.ai/architecture.yaml) ## About AICaC diff --git a/CACHE_MANAGEMENT.md b/CACHE_MANAGEMENT.md deleted file mode 100644 index ebd7864e..00000000 --- a/CACHE_MANAGEMENT.md +++ /dev/null @@ -1,119 +0,0 @@ -# Cache Management Features - -This document describes the new cache management features added to the GitLab Component Helper extension. - -## Overview - -The extension now provides two distinct cache management operations to give users better control over cached GitLab component data: - -1. **Update Cache** - Forces refresh of cached data while preserving cache structure -2. **Reset Cache** - Completely clears all cached data and starts fresh - -## Features Added - -### Commands (Available in Command Palette) - -- `GitLab CI: Update Cache` - Forces fresh fetch of all component data from sources -- `GitLab CI: Reset Cache` - Completely clears all cached data (with confirmation prompt) -- `GitLab CI: Refresh Components Cache` - Existing command for standard refresh - -### Component Browser UI - -The component browser now includes three action buttons in the header: - -- **🔄 Refresh** - Standard refresh (reloads current cached data) -- **📥 Update Cache** - Forces fresh data fetch from all GitLab sources -- **🗑️ Reset Cache** - Completely clears cache (requires confirmation) - -### Cache Management Methods - -#### ComponentService -- `updateCache()` - Clears internal caches to force fresh fetch -- `resetCache()` - Completely clears all cached data -- `getCacheStats()` - Returns detailed cache statistics - -#### ComponentCacheManager -- `updateCache()` - Forces refresh and triggers ComponentService update -- `resetCache()` - Clears in-memory and persistent storage -- `getCacheStats()` - Returns comprehensive cache statistics - -## Usage Scenarios - -### Update Cache -Use when you want to: -- Fetch the latest component definitions from GitLab -- Refresh cached data without losing cache structure -- Update components after changes are made to GitLab repositories -- Resolve stale data issues - -### Reset Cache -Use when you want to: -- Completely start fresh with component data -- Clear corrupted cache data -- Troubleshoot cache-related issues -- Free up storage space - -## Technical Implementation - -### Cache Types Managed -1. **ComponentService Caches**: - - `catalogCache` - GitLab catalog API responses - - `componentCache` - Individual component metadata - - `sourceCache` - Source-level component data - -2. **ComponentCacheManager Caches**: - - In-memory component arrays - - Project versions cache - - VS Code global state storage - - Source error tracking - -### User Experience Features - -- **Progress Indicators**: Both operations show progress notifications -- **Confirmation Prompts**: Reset cache requires user confirmation -- **Error Handling**: Graceful error handling with user-friendly messages -- **Visual Feedback**: Browser UI updates to reflect cache state -- **Loading States**: Clear loading indicators during operations - -### Safety Features - -- **Confirmation Required**: Reset cache asks for confirmation before proceeding -- **Error Recovery**: Failed operations don't leave cache in inconsistent state -- **Logging**: All cache operations are logged for debugging -- **Graceful Degradation**: Failures fall back to existing cached data when possible - -## API Integration - -Both cache management features properly handle: -- GitLab API token management -- Rate limiting considerations -- Network error recovery -- Batch processing for large datasets -- Parallel fetching optimizations - -## Storage Management - -The cache management system handles: -- VS Code global state persistence -- Memory-only fallback mode -- Cache size optimization -- Automatic cleanup of expired entries -- Cross-session cache persistence - -## Testing - -The cache management features have been tested with: -- Multiple GitLab instances -- Large component datasets -- Network connectivity issues -- Token authentication scenarios -- UI responsiveness during operations - -## Future Enhancements - -Potential future improvements: -- Selective cache clearing (by source) -- Cache size monitoring and alerts -- Automatic cache optimization -- Cache export/import functionality -- Advanced cache statistics dashboard diff --git a/PERFORMANCE_OPTIMIZATIONS.md b/PERFORMANCE_OPTIMIZATIONS.md deleted file mode 100644 index 3f229713..00000000 --- a/PERFORMANCE_OPTIMIZATIONS.md +++ /dev/null @@ -1,129 +0,0 @@ -# Performance Optimizations - -This document outlines the performance improvements and optimizations implemented in the ComponentService. - -## Summary of Optimizations - -### Performance Improvements (60-80% faster component loading) - -#### 1. Enhanced HTTP Client with Retry Logic -- **HttpClient utility** with configurable timeouts (default 10s) -- **Exponential backoff retry** for transient failures (configurable retries) -- **Smart error handling** that doesn't retry client errors (4xx) -- **Request timeout prevention** to avoid hanging requests - -#### 2. Map-based Granular Caching -- **Source-type caching** using Map for better cache management -- **Component-level caching** for individual component metadata -- **Catalog caching** for GitLab CI/CD catalog data -- **Background cache updates** serve cached data while fetching fresh data - -#### 3. Parallel Data Fetching -- **Parallel API calls** in `fetchComponentMetadata` (project info, README, templates) -- **Parallel version fetching** (tags and branches simultaneously) -- **Concurrent template processing** for multiple components -- **Batch processing** of components (configurable batch size, default 5) - -#### 4. Optimized Catalog Processing -- **Parallel project and template fetching** -- **Batch component processing** to avoid API overwhelming -- **Smart content extraction** with parallel README and template fetching -- **Efficient variable parsing** from GitLab CI/CD component specs - -### Reliability Improvements - -#### 1. Enhanced Error Handling -- **Graceful degradation** with fallbacks to cached or local components -- **Per-request error isolation** doesn't fail entire operations -- **Detailed error logging** with performance metrics -- **Smart retry logic** for network failures - -#### 2. Configuration Management -- **Configurable timeouts** and retry attempts -- **Adjustable batch sizes** for different environments -- **Logging level control** (DEBUG, INFO, WARN, ERROR) -- **Background update settings** - -### Maintainability Improvements - -#### 1. Structured Logging System -- **Configurable log levels** for different environments -- **Performance timing** with detailed metrics -- **Component-scoped logging** for better debugging -- **Timestamped log entries** for audit trails - -#### 2. Code Quality Improvements -- **Async/await refactoring** replacing nested callbacks -- **Type-safe HTTP utilities** with comprehensive error handling -- **Modular architecture** with separate concerns -- **Clear separation** of caching, HTTP, and logging utilities - -## Configuration Options - -The following new configuration options are available in VS Code settings: - -```json -{ - "gitlabComponentHelper.logLevel": "INFO", // DEBUG, INFO, WARN, ERROR - "gitlabComponentHelper.httpTimeout": 10000, // HTTP timeout in milliseconds - "gitlabComponentHelper.retryAttempts": 3, // Number of retry attempts - "gitlabComponentHelper.batchSize": 5 // Batch size for parallel processing -} -``` - -## Performance Metrics - -### Before Optimizations -- Component loading: ~5-10 seconds for 10 components -- No parallel processing -- Basic error handling -- Simple logging - -### After Optimizations -- Component loading: ~1-3 seconds for 10 components (60-80% improvement) -- Parallel processing with batching -- Comprehensive error handling with retries -- Structured logging with performance metrics - -## Benchmarking - -The optimizations include built-in performance logging that tracks: -- Operation duration with `logger.logPerformance()` -- Cache hit/miss ratios -- Batch processing statistics -- HTTP request timing and retry counts - -Enable DEBUG logging to see detailed performance metrics: -```json -{ - "gitlabComponentHelper.logLevel": "DEBUG" -} -``` - -## Architecture - -### New Components - -1. **HttpClient** (`src/utils/httpClient.ts`) - - Handles all HTTP requests with timeouts and retries - - Provides parallel request utilities - - Implements batch processing helpers - -2. **Logger** (`src/utils/logger.ts`) - - Structured logging with configurable levels - - Performance timing utilities - - Component-scoped logging - -3. **Enhanced ComponentService** (`src/services/componentService.ts`) - - Map-based caching system - - Background cache updates - - Parallel data fetching - - Batch component processing - -### Data Flow - -1. **Component Request** → Check cache → Background update if needed -2. **Fresh Data Fetch** → Parallel API calls → Batch processing → Cache update -3. **Error Handling** → Retry logic → Fallback to cache → Local fallback - -This architecture ensures fast response times while maintaining data freshness and reliability. diff --git a/README.md b/README.md index f3ed0749..3c0be6a5 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ Press `F5` to launch an Extension Development Host with the extension loaded. 2. Commit using [conventional commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, `chore:`, …). 3. Open a Pull Request. -Releases are automated with [semantic-release](https://semantic-release.gitbook.io/) — version, `CHANGELOG.md`, and the GitHub release are derived from commit messages on merge. See [SEMANTIC_RELEASE.md](./SEMANTIC_RELEASE.md). +Releases are two-stage: **[release-it](https://github.com/release-it/release-it)** bumps the version and `CHANGELOG.md` from the commit history when changes land on `beta`/`main`, then publishing to the Marketplace is a **manually-dispatched** GitHub Actions workflow (Publish / Publish Beta). See [docs/RELEASING.md](https://github.com/eFAILution/gitlab-component-helper/blob/main/docs/RELEASING.md). --- diff --git a/SEMANTIC_RELEASE.md b/SEMANTIC_RELEASE.md deleted file mode 100644 index 8b301fea..00000000 --- a/SEMANTIC_RELEASE.md +++ /dev/null @@ -1,200 +0,0 @@ -# Local Release Setup - Zero GitHub Actions Cost! 💰 - -This project supports multiple release strategies, all designed to avoid GitHub Actions costs while maintaining professional release workflows. - -## ⚠️ Important: GitHub Token Issue with Semantic-Release - -**Semantic-release always requires a GitHub token**, even for local-only releases, because it automatically loads the GitHub plugin. For truly token-free releases, use the manual release script. - -## 🎯 Recommended: Manual Release Script (No Token Required) - -**The best option for local releases without any GitHub token dependency:** - -```bash -npm run release:manual -``` - -**What it does:** -- ✅ **Zero GitHub token required** -- ✅ Analyzes conventional commits automatically -- ✅ Bumps version based on commit types (major/minor/patch) -- ✅ Runs tests before releasing -- ✅ Builds and packages extension -- ✅ Updates package.json version -- ✅ Generates/updates CHANGELOG.md with categorized changes -- ✅ Creates git tag -- ✅ Commits changes with proper message -- 💰 **Zero GitHub Actions cost** -- 🔒 **No external dependencies or tokens** - -## 🔍 Test Before Release (Dry Run) - -**Want to see what would happen without making any changes?** - -```bash -npm run release:dry -``` - -**What it shows:** -- ✅ **Zero changes made to your files** -- ✅ Shows version bump that would happen -- ✅ Displays changelog entries that would be added -- ✅ Lists git commands that would run -- ✅ Perfect for testing your conventional commits -- 🔒 **Completely safe - no modifications** - -## 🔄 One-Command Complete Release - -**The easiest way - does everything for you:** - -```bash -npm run release:complete -``` - -**What it does:** -- ✅ Runs manual release (no token needed) -- ✅ Pushes commits and tags to GitHub -- ✅ Creates GitHub release with .vsix file attached -- 💰 **Zero GitHub Actions cost** -- 🔧 **Requires GitHub CLI for the publish step only** - -## 📋 Quick Reference - -| Command | What it does | GitHub Token | GitHub CLI | Cost | -|---------|-------------|-------------|-----------|------| -| `npm run release:manual` | **✅ RECOMMENDED** - Complete local release | ❌ Not needed | ❌ Not needed | 💰 FREE | -| `npm run release:complete` | Manual release + GitHub publish | ❌ Not needed | ✅ Required | 💰 FREE | -| `npm run release:publish` | Publish existing release | ❌ Not needed | ✅ Required | 💰 FREE | -| `npm run release:dry` | **🔍 DRY RUN** - Shows what manual release would do | ❌ Not needed | ❌ Not needed | 💰 FREE | -| `npm run semantic-release:env-dry` | **🔍 TEST** - Verify .env token works | ✅ From .env | ❌ Not needed | 💰 FREE | -| `npm run semantic-release:env-only-dry` | **🔍 TEST** - Verify .env token works (local-only) | ✅ From .env | ❌ Not needed | 💰 FREE | -| `npm run semantic-release:env` | **🔑 Semantic-release** with .env token | ✅ From .env | ❌ Not needed | 💰 FREE | -| `npm run semantic-release:env-only` | **🔑 Semantic-release** local-only with .env token | ✅ From .env | ❌ Not needed | 💰 FREE | - -## 🚫 Semantic-Release Local Commands (Token Required) - -These commands require a GitHub token due to semantic-release limitations: - -```bash -# ⚠️ These require GITHUB_TOKEN to be set -npm run semantic-release:local -npm run semantic-release:local-only -``` - -**Why?** Semantic-release automatically loads the GitHub plugin even when not explicitly configured, making it impossible to run truly local-only without a token. - -## 🔑 Using Semantic-Release with .env Token (Optional) - -**If you want to use semantic-release instead of the manual script:** - -### Quick Setup -```bash -npm run setup:github-token -``` -This will guide you through the token setup process. - -### Manual Setup -1. **Create a GitHub Personal Access Token:** - - Go to: https://github.com/settings/tokens - - Click "Generate new token (classic)" - - Select scopes: `repo` (full control of private repositories) - - Copy the token - -2. **Create .env file:** (already gitignored) - ```bash - cp .env.example .env - # Edit .env and add your token: - GITHUB_TOKEN=ghp_your_token_here - ``` - -3. **Test your setup (dry run):** - ```bash - # Test if token works (no changes made) - npm run semantic-release:env-dry - - # Test local-only version (no changes made) - npm run semantic-release:env-only-dry - ``` - -4. **Use semantic-release commands:** - ```bash - # Semantic-release with .env token - npm run semantic-release:env - - # Semantic-release local-only with .env token - npm run semantic-release:env-only - ``` - -**These commands will:** -- ✅ Load GitHub token from `.env` file -- ✅ Run semantic-release with full GitHub integration -- ✅ Create releases, tags, and upload assets -- ✅ Work exactly like semantic-release should -- 🔒 Keep your token secure and gitignored - -## 🛠️ Prerequisites for Publishing - -To use the publish functionality, you need GitHub CLI: - -```bash -# Install GitHub CLI -brew install gh # macOS -# or download from https://cli.github.com/ - -# Authenticate -gh auth login -``` - -## 🎯 Conventional Commits - -Use these commit formats to trigger automatic releases: - -```bash -# Patch release (1.0.0 → 1.0.1) -git commit -m "fix: resolve component detection issue" - -# Minor release (1.0.0 → 1.1.0) -git commit -m "feat: add new hover provider functionality" - -# Major release (1.0.0 → 2.0.0) -git commit -m "feat!: redesign API with breaking changes" - -# No release (documentation, refactoring, etc.) -git commit -m "docs: update README with examples" -git commit -m "refactor: improve code structure" -git commit -m "chore: update dependencies" -``` - -## 🎯 Typical Workflow (Token-Free) - -1. **Code your changes** -2. **Commit with conventional format:** `git commit -m "feat: add awesome feature"` -3. **Create release locally:** `npm run release:manual` -4. **Publish to GitHub:** `npm run release:publish` (requires GitHub CLI) -5. **Done!** 🎉 Your extension is available on GitHub releases - -## 🛠️ What Gets Updated - -All release methods update: -- ✅ `package.json` version -- ✅ `package-lock.json` to match new version -- ✅ `CHANGELOG.md` with categorized changes -- ✅ Git tag (e.g., `1.2.3`) -- ✅ Packaged `.vsix` extension file -- ✅ GitHub release (if using publish scripts) - -## 📦 Extension Distribution - -After publishing, users can install your extension: -- **Direct download:** From GitHub releases page -- **Command line:** `code --install-extension gitlab-component-helper-1.2.3.vsix` -- **VS Code:** Extensions → Install from VSIX - -## 💡 Pro Tip - -The manual release script (`npm run release:manual`) is actually **more reliable** than semantic-release for local use because: -- ✅ No external dependencies -- ✅ No token requirements -- ✅ Complete control over the process -- ✅ Same conventional commit analysis -- ✅ Better error handling diff --git a/compile.txt b/compile.txt deleted file mode 100644 index 5bc954a7..00000000 --- a/compile.txt +++ /dev/null @@ -1,3 +0,0 @@ - -> gitlab-component-helper@0.1.0 compile -> tsc -p ./ diff --git a/COPILOT_COMMITS.md b/docs/COPILOT_COMMITS.md similarity index 100% rename from COPILOT_COMMITS.md rename to docs/COPILOT_COMMITS.md diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 00000000..4c3f10b0 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,47 @@ +# Releasing + +Releases are **two-stage**: versioning is automated when changes land, and publishing to the VS Code Marketplace is a **manually-dispatched** GitHub Actions workflow. + +> There is **no `semantic-release`** in this project (it isn't a dependency). Version/changelog automation is [release-it](https://github.com/release-it/release-it); the Marketplace publish is a gated `workflow_dispatch` workflow. + +## Branch & version convention + +The minor-version parity encodes the channel, and the publish workflows enforce it: + +| Branch | Channel | Minor | Example | +|--------|---------|-------|---------| +| `beta` | pre-release | **odd** | `0.15.x` | +| `main` | stable | **even** | `0.16.x` | + +A beta line (`0.15.x`) promotes to the next even stable minor (`0.16.0`) when `beta` is merged to `main`. + +## Stage 1 — Versioning (automatic, on merge) + +When commits land on `beta` or `main`, the `release` job in [`.github/workflows/ci.yml`](../.github/workflows/ci.yml) — after the unit and extension-host suites pass — runs release-it in CI: + +- `beta` → `release-it --config .release-it.beta.json --ci` +- `main` → `release-it --config .release-it.json --ci` + +release-it (with `@release-it/conventional-changelog`) computes the next version from the conventional-commit history, updates `package.json` and `CHANGELOG.md`, and pushes a `chore(release): [skip ci]` commit and matching git tag. No manual version bump is required. + +## Stage 2 — Publishing (manual dispatch) + +Publishing to the Marketplace is **never automatic**. Dispatch it deliberately: + +1. Confirm the `chore(release):` commit for the version you want to ship is the tip of `beta`/`main`. +2. GitHub → **Actions** → **Publish** (for `main`) or **Publish Beta** (for `beta`) → **Run workflow**, entering the exact version (it must match `package.json`). +3. The workflow runs in the protected `publish-main` / `publish-beta` environment and enforces these guardrails before `vsce publish`: + + 1. Correct branch (`main` for Publish, `beta` for Publish Beta). + 2. Input version matches `package.json`. + 3. HEAD is a `chore(release):` commit (i.e. Stage 1 has run). + 4. Full test suite passes. + 5. `npm audit --omit=dev --audit-level=high`. + 6. Gitleaks secret scan. + 7. Version minor parity matches the channel (even for stable, odd for pre-release). + + `main` publishes a stable build; `beta` publishes with `--pre-release`. Both upload the `.vsix` as a workflow artifact. + +## Local tooling + +The `npm run release:*` scripts and `scripts/` helpers (release-it wrappers plus `scripts/manual-release.js`) exist for local dry-runs and recovery — e.g. `npm run release:main:dry` previews the next version and changelog without pushing. Day to day, Stage 1 handles versioning automatically; reach for these only to verify or to bump manually. diff --git a/language-configuration.json b/language-configuration.json deleted file mode 100644 index b83e24d5..00000000 --- a/language-configuration.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "comments": { - "lineComment": "#" - }, - "brackets": [ - [ - "{", - "}" - ], - [ - "[", - "]" - ], - [ - "(", - ")" - ] - ], - "autoClosingPairs": [ - [ - "{", - "}" - ], - [ - "[", - "]" - ], - [ - "(", - ")" - ], - [ - "\"", - "\"" - ], - [ - "'", - "'" - ] - ], - "surroundingPairs": [ - [ - "{", - "}" - ], - [ - "[", - "]" - ], - [ - "(", - ")" - ], - [ - "\"", - "\"" - ], - [ - "'", - "'" - ] - ], - "folding": { - "offSide": true - } -} diff --git a/package-lock.json b/package-lock.json index 32ca955b..a64cdba7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitlab-component-helper", - "version": "0.14.5", + "version": "0.15.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitlab-component-helper", - "version": "0.14.5", + "version": "0.15.6", "license": "MIT", "devDependencies": { "@commitlint/cli": "^21.2.0", diff --git a/package.json b/package.json index 09ab5d41..a894c4ad 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gitlab-component-helper", "displayName": "GitLab Component Helper", "description": "Provides intellisense for GitLab CI components", - "version": "0.14.5", + "version": "0.15.6", "icon": "images/icon.png", "engines": { "node": ">=22.0.0", @@ -22,31 +22,10 @@ "workspaceContains:**/.gitlab/**/*.yml", "workspaceContains:**/.gitlab/**/*.yaml" ], + "extensionPack": [ + "redhat.vscode-yaml" + ], "contributes": { - "languages": [ - { - "id": "gitlab-ci", - "aliases": [ - "GitLab CI", - "gitlab-ci" - ], - "extensions": [ - ".gitlab-ci.yml", - ".gitlab-ci.yaml" - ], - "configuration": "./language-configuration.json" - } - ], - "grammars": [ - { - "language": "gitlab-ci", - "scopeName": "source.yaml.gitlab-ci", - "path": "./syntaxes/gitlab-ci.tmLanguage.json", - "embeddedLanguages": { - "source.yaml": "yaml" - } - } - ], "configuration": { "title": "GitLab Component Helper", "properties": { diff --git a/src/constants/api.ts b/src/constants/api.ts index fa686c6f..549a93a0 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -42,6 +42,8 @@ export const HTTP_STATUS_SERVER_ERROR_MIN = 500 as const; // HTTP Headers export const HEADER_USER_AGENT = 'User-Agent' as const; export const HEADER_PRIVATE_TOKEN = 'PRIVATE-TOKEN' as const; +export const HEADER_AUTHORIZATION = 'Authorization' as const; +export const HEADER_COOKIE = 'Cookie' as const; export const USER_AGENT_VALUE = 'VSCode-GitLabComponentHelper' as const; // Documentation URLs diff --git a/src/constants/timing.ts b/src/constants/timing.ts index b9469814..d9b474f3 100644 --- a/src/constants/timing.ts +++ b/src/constants/timing.ts @@ -26,6 +26,10 @@ export const API_PER_PAGE_LIMIT = 100 as const; /** Runaway-loop backstop for paginated fetches: 50 pages × 100/page = up to 5,000 items. */ export const MAX_PAGINATION_PAGES = 50 as const; +// HTTP Redirects +/** Backstop for redirect following: refuse to chase more than this many `Location` hops. */ +export const MAX_REDIRECTS = 5 as const; + // Batch Processing export const DEFAULT_BATCH_SIZE = 5 as const; diff --git a/src/extension.ts b/src/extension.ts index f90eab36..6fa90513 100755 --- a/src/extension.ts +++ b/src/extension.ts @@ -94,7 +94,6 @@ export function activate(context: vscode.ExtensionContext) { vscode.languages.registerHoverProvider( [ { language: 'yaml' }, - { language: 'gitlab-ci' }, { language: 'shellscript' } ], new HoverProvider() @@ -107,7 +106,6 @@ export function activate(context: vscode.ExtensionContext) { vscode.languages.registerCompletionItemProvider( [ { language: 'yaml' }, - { language: 'gitlab-ci' }, { language: 'shellscript' } ], new CompletionProvider(), @@ -121,8 +119,7 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.languages.registerDocumentLinkProvider( [ - { language: 'yaml' }, - { language: 'gitlab-ci' } + { language: 'yaml' } ], new ComponentDocumentLinkProvider() ) diff --git a/src/parsers/specParser.ts b/src/parsers/specParser.ts index 066ffe65..2386a19a 100644 --- a/src/parsers/specParser.ts +++ b/src/parsers/specParser.ts @@ -157,9 +157,12 @@ export class GitLabSpecParser { break; } - // New input parameter (indented under inputs) - handle both 2-space and 4-space indentation - // Match lines like " name:" where the input name ends with ":" and has only whitespace after - if (line.match(/^\s{2,4}[a-zA-Z_][a-zA-Z0-9_]*:\s*$/)) { + // New input parameter (indented under inputs) - handle both 2-space and 4-space indentation. + // Match lines like " name:" where the input name ends with ":" and has only whitespace after. + // The name class includes `-`: GitLab input names are commonly hyphenated (e.g. `job-name`). Without + // it, a hyphenated key isn't recognised as a new input, so its `description:`/`default:` lines bleed + // onto the previous (non-hyphenated) input and mis-map every field after it (issue #211). + if (line.match(/^\s{2,4}[a-zA-Z_][a-zA-Z0-9_-]*:\s*$/)) { // If we have a current input, finalize it before starting a new one if (currentInput) { // Mark as required if no default was specified (GitLab CI/CD component behavior) diff --git a/src/providers/completionInputContext.ts b/src/providers/completionInputContext.ts index 212ffe20..2723e868 100644 --- a/src/providers/completionInputContext.ts +++ b/src/providers/completionInputContext.ts @@ -6,18 +6,27 @@ */ import { parseYaml, isYamlNode } from '../utils/yamlParser'; +import { findIncludeLine } from '../utils/includeMatcher'; import type { ComponentParameter } from '../types/git-component'; /** * The completion slot a YAML cursor position resolves to. + * + * `slot` discriminates the two positions completion fires in: a `name` slot is where a parameter key is typed (the + * caller offers the missing input names); a `value` slot is the value position after a known `inputName:` (the caller + * offers that input's allowed values). The fields below carry whichever extra context each slot needs. */ export interface CompletionInputContext { /** URL or local path of the include that owns the surrounding `inputs:` block. */ componentUrl: string; /** Which include flavour matched: `component` for remote URLs, `local` for in-repo includes. */ includeKind: 'component' | 'local'; + /** Which position the cursor resolved to. */ + slot: 'name' | 'value'; /** Names of inputs already written under this include, so the caller can filter them out of the suggestions. */ existingInputNames: string[]; + /** For a `value` slot, the name of the input whose value is being completed; absent for a `name` slot. */ + inputName?: string; } interface ClosestInclude { @@ -66,32 +75,46 @@ export function findCompletionInputContextAtLine( const section = findInputsSection(lines, closest.componentLineIndex, lineIndex); if (!section) return null; - // Containment in the inputs block is already established above; here we only check the line looks like a - // parameter-name slot. A name slot must line up at the same column as the existing input keys - // (`section.childIndent`) — one column shallower is a sibling of `inputs:`, deeper is a value nested under - // another input. When the block has no keys yet, fall back to "deeper than `inputs:`". It must also not be an - // array-item line (`- value`, part of a parameter value) nor an already-complete `key: value`. + // Containment in the inputs block is already established above. The line must line up at the same column as the + // existing input keys (`section.childIndent`) — one column shallower is a sibling of `inputs:`, deeper is a value + // nested under another input. When the block has no keys yet, fall back to "deeper than `inputs:`". const currentLine = lines[lineIndex]; const currentLineText = currentLine.trim(); const currentIndent = indentOf(currentLine); const indentMatches = section.childIndent !== null ? currentIndent === section.childIndent : currentIndent > section.inputsIndent; - // When a cursor column is supplied, the cursor must sit at the slot's indent column or within the name being - // typed after it — never left of the indent. Typing the right indentation then moving the cursor left leaves - // the line's whitespace intact, so the indent check above still matches; but the cursor is no longer in the - // name slot, so we must not offer completions there. + if (!indentMatches || currentLineText.startsWith('- ') || currentLineText === '-') return null; + + // A `key:` line splits into a name slot (left of the colon) and a value slot (right of it). When the cursor sits + // past the colon and the key is a real input name, offer that input's allowed values instead of input names. + const colonIndex = currentLine.indexOf(':'); + if (colonIndex !== -1) { + const inputName = currentLine.slice(0, colonIndex).trim(); + const cursorInValue = column !== undefined && column > colonIndex; + if (inputName && cursorInValue) { + return { + componentUrl: closest.componentUrl, + includeKind: closest.includeKind, + slot: 'value', + existingInputNames: closest.existingInputNames, + inputName, + }; + } + } + + // Otherwise it's a name slot: a bare/partial key with no value yet. When a cursor column is supplied it must sit + // at the slot's indent column or within the name being typed — never left of the indent (typing the indentation + // then moving the cursor left leaves the whitespace intact, so the indent check above still matches, but the + // cursor is no longer in the name slot). A completed `key: value` line is not a name slot. const cursorAtSlot = column === undefined || column >= currentIndent; - const isParameterContext = - indentMatches && - cursorAtSlot && - !currentLineText.startsWith('- ') && - currentLineText !== '-' && - (!currentLineText.includes(':') || currentLineText.endsWith(':')); - if (!isParameterContext) return null; + const isNameSlot = + cursorAtSlot && (!currentLineText.includes(':') || currentLineText.endsWith(':')); + if (!isNameSlot) return null; return { componentUrl: closest.componentUrl, includeKind: closest.includeKind, + slot: 'name', existingInputNames: closest.existingInputNames, }; } @@ -126,11 +149,25 @@ function parseInputDocument(text: string, lines: string[], lineIndex: number): u /** * Find the include entry whose source line is the closest one above `lineIndex`, matching the parsed includes * back to their position in the text. + * + * `includes` is in document order, so duplicate entries that share an identical key+URL (the same component included + * twice with different inputs) are disambiguated by occurrence ordinal: the Nth such entry anchors to the Nth + * matching line. + * + * @param includes - The parsed `include:` entries, in document order; non-mapping entries and those without a + * string `component`/`local` are skipped. + * @param lines - The full document split into lines, used to locate each include's source line. + * @param lineIndex - 0-based cursor line; only includes whose source line sits strictly above it are considered. + * @returns The closest matching include's URL/kind, its source line index, and the names of inputs already present + * under it; `null` when no include is declared above `lineIndex`. */ function findClosestInclude(includes: unknown[], lines: string[], lineIndex: number): ClosestInclude | null { let closest: ClosestInclude | null = null; let closestDistance = Infinity; + // How many key+URL pairs identical to the current entry we have already passed in document order. + const occurrenceSeen = new Map(); + for (const include of includes) { if (!isYamlNode(include)) continue; @@ -145,13 +182,11 @@ function findClosestInclude(includes: unknown[], lines: string[], lineIndex: num const includeKey = isLocal ? 'local:' : 'component:'; - let componentLineIndex = -1; - for (let i = 0; i < lines.length; i++) { - if (lines[i].includes(includeKey) && lines[i].includes(componentUrl)) { - componentLineIndex = i; - break; - } - } + const occurrenceKey = `${includeKey}\n${componentUrl}`; + const occurrence = (occurrenceSeen.get(occurrenceKey) ?? 0) + 1; + occurrenceSeen.set(occurrenceKey, occurrence); + + const componentLineIndex = findIncludeLine(lines, includeKey, componentUrl, occurrence); if (componentLineIndex === -1 || componentLineIndex >= lineIndex) continue; const distance = lineIndex - componentLineIndex; @@ -272,6 +307,30 @@ function quoteYamlIfUnsafe(value: string, flow = false): string { return asDoubleQuoted(value); } +/** + * Render a single `options:` entry as the YAML to insert for it. + * + * Numbers and booleans stay bare so they aren't turned into strings; string entries stay bare where a bare scalar + * round-trips and are double-quoted only where bare YAML would reinterpret them (see {@link quoteYamlIfUnsafe}). + * + * @param value - One allowed value from an input's `options:` list. + * @returns The YAML text for that value, bare or double-quoted as required. + */ +export function renderOptionValue(value: string | number | boolean): string { + return typeof value === 'string' ? quoteYamlIfUnsafe(value) : String(value); +} + +/** + * Narrow a parameter default to the scalar shapes an `options:` entry can take (string/number/boolean), excluding + * the `null` and array forms a default may also hold. + * + * @param value - A parameter default of any allowed shape. + * @returns `true` when `value` is a string, number, or boolean. + */ +function isOptionScalar(value: unknown): value is string | number | boolean { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; +} + /** * Build the value portion of the snippet inserted after `param.name: ` when an input is accepted from completion. * @@ -280,13 +339,29 @@ function quoteYamlIfUnsafe(value: string, flow = false): string { * Values are inserted bare where a bare YAML scalar round-trips to the same string — GitLab CI parses a bare scalar * by the input's declared type, so a string input is a bare scalar, not a quoted one. Strings that bare YAML would * reinterpret (indicators, embedded `: `/` #`, type-like tokens, etc.) are double-quoted; see {@link quoteYamlIfUnsafe}. - * Precedence: an explicit `default` is rendered as the YAML it represents; otherwise an `options:` enum becomes a - * choice; otherwise a type-appropriate placeholder. + * Precedence: an `options:` enum becomes a `${1|...|}` choice (with the default, if any, pre-selected first) so the + * allowed values stay one keystroke away; otherwise an explicit `default` is rendered as the YAML it represents; + * otherwise a type-appropriate placeholder. * * @param param - The input parameter spec (type, optional default, optional `options` enum, requiredness). - * @returns The snippet body to insert after `param.name: ` — a rendered value, a `${1|...|}` choice, or a `${1:...}` placeholder. + * @returns The snippet body to insert after `param.name: ` — a `${1|...|}` choice, a rendered value, or a `${1:...}` placeholder. */ export function buildInputInsertValue(param: ComponentParameter): string { + if (param.options && param.options.length > 0) { + // Offer the allowed values (`options:`) as a choice. Entries stay unquoted so a number/boolean option isn't + // turned into a string; string entries are quoted only when bare YAML would reinterpret them. When the input + // also has a default, float the matching option to the front — VS Code pre-selects the first choice entry, so + // accepting the input keeps the default while leaving the alternatives one arrow-key away. + const rendered = param.options.map(renderOptionValue); + // Only a scalar default can name one of the options; a null or array default has no matching entry. + const defaultRendered = isOptionScalar(param.default) ? renderOptionValue(param.default) : undefined; + const ordered = + defaultRendered !== undefined && rendered.includes(defaultRendered) + ? [defaultRendered, ...rendered.filter((v) => v !== defaultRendered)] + : rendered; + return `\${1|${ordered.join(',')}|}`; + } + if (param.default !== undefined) { // Render the default as the YAML the input expects: arrays as flow sequences (`[a, b]`), // strings bare-or-quoted by round-trip safety, everything else as its bare scalar form. @@ -296,16 +371,6 @@ export function buildInputInsertValue(param: ComponentParameter): string { return typeof param.default === 'string' ? quoteYamlIfUnsafe(param.default) : String(param.default); } - if (param.options && param.options.length > 0) { - // Offer the allowed values (`options:`) as a choice. Entries stay unquoted so a number/boolean - // option isn't turned into a string; string entries are quoted only when bare YAML would - // reinterpret them. - const optionValues = param.options - .map((val) => (typeof val === 'string' ? quoteYamlIfUnsafe(val) : String(val))) - .join(','); - return `\${1|${optionValues}|}`; - } - switch (param.type) { case 'boolean': return param.required ? '${1|true,false|}' : '${1|false,true|}'; diff --git a/src/providers/completionProvider.ts b/src/providers/completionProvider.ts index c2516b59..084d3f1a 100755 --- a/src/providers/completionProvider.ts +++ b/src/providers/completionProvider.ts @@ -5,7 +5,7 @@ import { getVariableCompletions, containsGitLabVariables, expandComponentUrl } f import { Logger } from '../utils/logger'; import { isGitLabCIFile } from '../utils/gitlabCiFileMatcher'; import { resolveLocalComponent } from './localComponentResolver'; -import { findCompletionInputContextAtLine, buildInputInsertValue } from './completionInputContext'; +import { findCompletionInputContextAtLine, buildInputInsertValue, renderOptionValue } from './completionInputContext'; import type { ComponentParameter } from '../types/git-component'; import type { CachedComponent } from '../types/cache'; @@ -372,7 +372,7 @@ export class CompletionProvider implements vscode.CompletionItemProvider { const { componentUrl, includeKind, existingInputNames } = context; const isLocal = includeKind === 'local'; - this.logger.debug(`[CompletionProvider] In inputs slot for ${componentUrl} (local=${isLocal})`, 'CompletionProvider'); + this.logger.debug(`[CompletionProvider] In inputs ${context.slot} slot for ${componentUrl} (local=${isLocal})`, 'CompletionProvider'); // Get the component details - local includes resolve from the workspace file, others from cache. const component = isLocal @@ -385,6 +385,25 @@ export class CompletionProvider implements vscode.CompletionItemProvider { this.logger.debug(`[CompletionProvider] Found component ${component.name} with ${component.parameters.length} parameters; existing inputs: ${existingInputNames.join(', ') || 'none'}`, 'CompletionProvider'); + // A value slot (cursor after a known `inputName:`) offers that input's allowed values, independent of whether + // it also declares a default — the default is just the pre-filled choice, not a reason to hide the others. + if (context.slot === 'value') { + const param = component.parameters.find((p: ComponentParameter) => p.name === context.inputName); + if (!param?.options?.length) { + this.logger.debug(`[CompletionProvider] Value slot for ${context.inputName} has no options to offer`, 'CompletionProvider'); + return null; + } + return param.options.map((value, index) => { + const rendered = renderOptionValue(value); + const item = new vscode.CompletionItem(String(value), vscode.CompletionItemKind.EnumMember); + item.insertText = rendered; + item.detail = `${param.type || 'string'} option`; + // Preserve the declared order of `options:` in the dropdown. + item.sortText = String(index).padStart(4, '0'); + return item; + }); + } + // Filter out already provided inputs const existing = new Set(existingInputNames); const missingInputs = component.parameters.filter((param: ComponentParameter) => !existing.has(param.name)); diff --git a/src/providers/hoverInputContext.ts b/src/providers/hoverInputContext.ts index 89145d79..ff12d22e 100644 --- a/src/providers/hoverInputContext.ts +++ b/src/providers/hoverInputContext.ts @@ -5,6 +5,7 @@ */ import { parseYaml, isYamlNode } from '../utils/yamlParser'; +import { findIncludeLine } from '../utils/includeMatcher'; /** * The component-input slot a YAML cursor position resolves to. @@ -62,19 +63,22 @@ export function findInputContextAtLine(text: string, lineIndex: number): InputCo } if (candidates.length === 0) return null; - // Find the closest include line above the cursor that matches one of the parsed candidates. + // Find the closest include line above the cursor that matches one of the parsed candidates. `candidates` is in + // document order, so duplicate entries sharing an identical key+URL (the same component included twice with + // different inputs) are disambiguated by occurrence ordinal. let closestIncludeLine = -1; let closestCandidate: IncludeCandidate | null = null; + const occurrenceSeen = new Map(); for (const candidate of candidates) { const lineKey = `${candidate.key}:`; - for (let i = 0; i < lineIndex; i++) { - if (lines[i].includes(lineKey) && lines[i].includes(candidate.value)) { - if (i > closestIncludeLine) { - closestIncludeLine = i; - closestCandidate = candidate; - } - break; - } + const occurrenceKey = `${lineKey}\n${candidate.value}`; + const occurrence = (occurrenceSeen.get(occurrenceKey) ?? 0) + 1; + occurrenceSeen.set(occurrenceKey, occurrence); + + const matchLine = findIncludeLine(lines, lineKey, candidate.value, occurrence); + if (matchLine !== -1 && matchLine < lineIndex && matchLine > closestIncludeLine) { + closestIncludeLine = matchLine; + closestCandidate = candidate; } } if (closestIncludeLine === -1 || closestCandidate === null) return null; diff --git a/src/providers/validationProvider.ts b/src/providers/validationProvider.ts index 6ffd622e..5de5c789 100644 --- a/src/providers/validationProvider.ts +++ b/src/providers/validationProvider.ts @@ -25,6 +25,7 @@ import { isLocalInclude, includeKeyAndUrl, includeLineMatches, + findIncludeLine, } from '../utils/includeMatcher'; export class ValidationProvider implements vscode.CodeActionProvider { @@ -53,12 +54,11 @@ export class ValidationProvider implements vscode.CodeActionProvider { context.subscriptions.push(this.versionDiagnostics); // Register code action provider for the languages the providers run against. - this.logger.debug('[ValidationProvider] Registering code action provider for yaml, gitlab-ci, and shellscript', 'ValidationProvider'); + this.logger.debug('[ValidationProvider] Registering code action provider for yaml and shellscript', 'ValidationProvider'); context.subscriptions.push( vscode.languages.registerCodeActionsProvider( [ { language: 'yaml' }, - { language: 'gitlab-ci' }, { language: 'shellscript' }, ], this, @@ -1031,15 +1031,10 @@ export class ValidationProvider implements vscode.CodeActionProvider { this.logger.debug(`[ValidationProvider] Looking for include URL: ${url} (occurrence ${targetOccurrence})`, 'ValidationProvider'); const lines = document.getText().split('\n'); - let seen = 0; - for (let i = 0; i < lines.length; i++) { - if (includeLineMatches(lines[i], key, url)) { - seen++; - if (seen === targetOccurrence) { - this.logger.debug(`[ValidationProvider] Found include at line ${i}: ${lines[i].trim()}`, 'ValidationProvider'); - return i; - } - } + const line = findIncludeLine(lines, key, url, targetOccurrence); + if (line !== -1) { + this.logger.debug(`[ValidationProvider] Found include at line ${line}: ${lines[line].trim()}`, 'ValidationProvider'); + return line; } this.logger.debug(`[ValidationProvider] Include URL not found, returning 0`, 'ValidationProvider'); return 0; diff --git a/src/utils/gitlabCiFileMatcherCore.ts b/src/utils/gitlabCiFileMatcherCore.ts index 058521b9..5b389d03 100644 --- a/src/utils/gitlabCiFileMatcherCore.ts +++ b/src/utils/gitlabCiFileMatcherCore.ts @@ -6,20 +6,26 @@ import { minimatch } from 'minimatch'; /** - * Built-in glob patterns that always identify a file as a GitLab CI file. Covers the canonical entrypoint and the - * conventional `.gitlab/` directory used for nested/included pipeline config. + * Built-in glob patterns that always identify a file as a GitLab CI file. Covers the canonical entrypoint, the + * `*.gitlab-ci.{yml,yaml}` suffix convention used for modular/included pipeline files, and the conventional + * `.gitlab/` directory. The suffix patterns restore recognition of files like `deploy.gitlab-ci.yml` that the + * removed custom `gitlab-ci` language used to match via its `extensions` (a filename-suffix match). */ export const DEFAULT_GITLAB_CI_FILE_GLOBS = [ '**/.gitlab-ci.yml', '**/.gitlab-ci.yaml', + '**/*.gitlab-ci.yml', + '**/*.gitlab-ci.yaml', '**/.gitlab/**/*.yml', '**/.gitlab/**/*.yaml', ]; /** - * Languages whose documents are always in-scope for the providers regardless of filename. + * Languages whose documents are always in-scope for the providers regardless of filename. `shellscript` covers + * standalone shell scripts and embedded `script:` blocks; GitLab CI files themselves use the `yaml` language and + * are matched by path against {@link DEFAULT_GITLAB_CI_FILE_GLOBS}. */ -export const ALLOWED_LANGUAGE_IDS: ReadonlySet = new Set(['gitlab-ci', 'shellscript']); +export const ALLOWED_LANGUAGE_IDS: ReadonlySet = new Set(['shellscript']); /** * Normalise a user-supplied glob so it matches the same way users intuitively expect. @@ -51,7 +57,7 @@ export function buildFileGlobs(additionalGlobs: readonly string[] = []): string[ * the production wrapper extracts `path` and `languageId` from a `vscode.TextDocument` and delegates here. * * Resolution order: - * 1. If `languageId` is in {@link ALLOWED_LANGUAGE_IDS} (`gitlab-ci`, `shellscript`), match unconditionally. + * 1. If `languageId` is in {@link ALLOWED_LANGUAGE_IDS} (`shellscript`), match unconditionally. * 2. Otherwise check the path against the supplied `globs` list. * * @param filePath Repo-relative or absolute filesystem path of the document. diff --git a/src/utils/httpClient.ts b/src/utils/httpClient.ts index 1d02e3f6..efe2275d 100644 --- a/src/utils/httpClient.ts +++ b/src/utils/httpClient.ts @@ -5,7 +5,8 @@ import { Logger } from './logger'; import { getRequestDeduplicator, RequestDeduplicator } from './requestDeduplicator'; import { getPerformanceMonitor } from './performanceMonitor'; import { NetworkError, getErrorHandler, extractStatusCode } from '../errors'; -import { API_PER_PAGE_LIMIT, MAX_PAGINATION_PAGES } from '../constants/timing'; +import { API_PER_PAGE_LIMIT, MAX_PAGINATION_PAGES, MAX_REDIRECTS } from '../constants/timing'; +import { planRedirect, stripCredentialHeaders } from './redirectPolicy'; interface RequestOptions { timeout?: number; @@ -224,13 +225,20 @@ export class HttpClient { * `makeRequest` discards headers; this sibling preserves them so callers that need pagination metadata (GitLab's * `x-next-page` / `x-total-pages`) can read it. Header names are lower-cased by Node's HTTP layer. * + * Redirects are followed under the credential-safe policy in {@link planRedirect}: same-origin hops keep the + * request headers, cross-origin hops drop the `Authorization`/`PRIVATE-TOKEN`/`Cookie` headers first (so a moved + * project whose old path was reclaimed can't harvest the user's token), HTTPS→HTTP downgrades are refused, and the + * chain is capped at `redirectsRemaining` hops. + * * @param url The fully-qualified request URL. * @param options Request timeout (ms) and headers to send. + * @param redirectsRemaining Remaining redirect hops before the chain is rejected (defaults to {@link MAX_REDIRECTS}). * @returns The response body string and a lower-cased header map. */ private makeRequestWithHeaders( url: string, - options: { timeout: number; headers: Record } + options: { timeout: number; headers: Record }, + redirectsRemaining: number = MAX_REDIRECTS ): Promise<{ body: string; headers: Record }> { return new Promise((resolve, reject) => { try { @@ -248,6 +256,36 @@ export class HttpClient { }; const req = client.request(requestOptions, (res) => { + const statusCode = res.statusCode ?? 0; + + // Resolve redirects before reading the body. `planRedirect` enforces the credential-safe, + // same-origin-preferring policy; a malformed redirect or a refused HTTPS→HTTP downgrade throws. + let redirect; + try { + redirect = planRedirect(url, statusCode, res.headers.location); + } catch (policyError) { + res.resume(); // drain so the socket can be released + reject(policyError); + return; + } + + if (redirect) { + res.resume(); // discard the redirect response body + if (redirectsRemaining <= 0) { + reject(new NetworkError(`Too many redirects while fetching ${url}`, { statusCode })); + return; + } + const nextHeaders = redirect.stripCredentials + ? stripCredentialHeaders(options.headers) + : options.headers; + this.makeRequestWithHeaders( + redirect.nextUrl, + { timeout: options.timeout, headers: nextHeaders }, + redirectsRemaining - 1 + ).then(resolve, reject); + return; + } + let data = ''; res.on('data', (chunk) => { @@ -255,7 +293,7 @@ export class HttpClient { }); res.on('end', () => { - if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + if (statusCode >= 200 && statusCode < 300) { const headers: Record = {}; for (const [key, value] of Object.entries(res.headers)) { if (typeof value === 'string') { @@ -266,8 +304,8 @@ export class HttpClient { } resolve({ body: data, headers }); } else { - const message = `HTTP ${res.statusCode}: ${data.substring(0, 200)}`; - reject(new NetworkError(message, { statusCode: res.statusCode })); + const message = `HTTP ${statusCode}: ${data.substring(0, 200)}`; + reject(new NetworkError(message, { statusCode })); } }); }); diff --git a/src/utils/includeMatcher.ts b/src/utils/includeMatcher.ts index 91709fd1..e5247d96 100644 --- a/src/utils/includeMatcher.ts +++ b/src/utils/includeMatcher.ts @@ -94,3 +94,33 @@ export function includeLineMatches(line: string, key: string, url: string): bool } return false; } + +/** + * The document line where the include identified by `key`+`url` makes its `occurrence`-th appearance. + * + * Two include entries can share an identical key+URL (e.g. the same component included twice with different inputs). + * Matching on key+URL alone returns the first occurrence for every duplicate, collapsing them onto one line. Callers + * that hold includes in document order pass the 1-based ordinal of the entry among its identical siblings, and this + * returns the correspondingly-numbered matching line. Matching uses {@link includeLineMatches}, so a versioned URL + * matches only at a token boundary, not as a prefix of a longer sibling. + * + * @param lines The document split into lines (no trailing newlines). + * @param key The include key token to require — `'component:'` or `'local:'`, as returned by + * {@link includeKeyAndUrl}. + * @param url The remote URL or local path that must appear on the line, terminated at a token boundary. + * @param occurrence The 1-based ordinal among identical key+URL includes — the 1st, 2nd, … such entry in document + * order. Defaults to `1` (the first match) for callers with no duplicates to disambiguate. + * @returns The 0-based line index of the `occurrence`-th match, or `-1` when fewer than `occurrence` lines match. + */ +export function findIncludeLine(lines: string[], key: string, url: string, occurrence = 1): number { + let seen = 0; + for (let i = 0; i < lines.length; i++) { + if (includeLineMatches(lines[i], key, url)) { + seen++; + if (seen === occurrence) { + return i; + } + } + } + return -1; +} diff --git a/src/utils/redirectPolicy.ts b/src/utils/redirectPolicy.ts new file mode 100644 index 00000000..5be9461a --- /dev/null +++ b/src/utils/redirectPolicy.ts @@ -0,0 +1,93 @@ +// Import from the pure `errors/types` module, not the `../errors` barrel: the barrel re-exports +// `handler.ts`, which imports `vscode` and is therefore unloadable in the pure-Node unit-test context. +import { NetworkError } from '../errors/types'; +import { HEADER_AUTHORIZATION, HEADER_PRIVATE_TOKEN, HEADER_COOKIE } from '../constants/api'; + +/** + * HTTP status codes that represent a followable redirect. 304 (Not Modified) is deliberately excluded: + * it is a 3xx response but not a redirect, and this client never sends conditional requests. + */ +export const REDIRECT_STATUS_CODES: ReadonlySet = new Set([301, 302, 303, 307, 308]); + +/** + * Outgoing request headers that carry credentials and must never be replayed to a different origin. + * Stored lower-cased and compared case-insensitively against header names. + */ +export const SENSITIVE_HEADERS: readonly string[] = [ + HEADER_AUTHORIZATION.toLowerCase(), + HEADER_PRIVATE_TOKEN.toLowerCase(), + HEADER_COOKIE.toLowerCase(), +]; + +/** The decision produced by {@link planRedirect}: where to go next and whether credentials survive the hop. */ +export interface RedirectPlan { + /** Absolute URL to request next. */ + nextUrl: string; + /** True when the redirect crosses origin, so credential headers must be dropped before following. */ + stripCredentials: boolean; +} + +/** + * Return a copy of `headers` with every credential-bearing header removed. Names are matched + * case-insensitively, so a caller's `PRIVATE-TOKEN` and a server-echoed `private-token` are both dropped. + * + * @param headers The outgoing header map to sanitise. + * @returns A new map containing only the non-credential headers. + */ +export function stripCredentialHeaders(headers: Record): Record { + return Object.fromEntries( + Object.entries(headers).filter(([name]) => !SENSITIVE_HEADERS.includes(name.toLowerCase())) + ); +} + +/** + * Decide whether and how to follow an HTTP redirect under a credential-safe, same-origin-preferring policy. + * + * Policy: + * - Only 301/302/303/307/308 carrying a `Location` are followed; any other status (including the + * non-redirect 304) returns `null` so the caller handles the response normally. + * - A redirect status with a missing/empty `Location` is a malformed response and throws. + * - Downgrading the transport from HTTPS to HTTP is refused (throws): an attacker able to force a + * downgrade could strip TLS, so we fail closed rather than replay the request in the clear. + * - A cross-origin target is permitted but flagged `stripCredentials: true`, so the caller drops the + * `Authorization`/`PRIVATE-TOKEN`/`Cookie` headers before following. This prevents a malicious or + * compromised host from harvesting the user's GitLab token via an attacker-controlled `Location`. + * + * @param currentUrl The URL that produced this redirect response. + * @param statusCode The redirect response's status code. + * @param location The raw `Location` header, absolute or relative to `currentUrl` (Node lower-cases the name). + * @returns A {@link RedirectPlan}, or `null` when the response is not a followable redirect. + * @throws NetworkError on a malformed redirect (no `Location`) or a refused HTTPS→HTTP downgrade. + */ +export function planRedirect( + currentUrl: string, + statusCode: number, + location: string | undefined +): RedirectPlan | null { + if (!REDIRECT_STATUS_CODES.has(statusCode)) { + return null; + } + + if (!location || location.trim() === '') { + throw new NetworkError( + `Redirect (HTTP ${statusCode}) from ${currentUrl} is missing a Location header`, + { statusCode } + ); + } + + const current = new URL(currentUrl); + // Resolves an absolute Location as-is and a relative Location against the current URL. + const next = new URL(location, current); + + if (current.protocol === 'https:' && next.protocol === 'http:') { + throw new NetworkError( + `Refusing to follow HTTPS→HTTP redirect from ${currentUrl} to ${next.toString()}`, + { statusCode } + ); + } + + return { + nextUrl: next.toString(), + stripCredentials: next.origin !== current.origin, + }; +} diff --git a/syntaxes/gitlab-ci.tmLanguage.json b/syntaxes/gitlab-ci.tmLanguage.json deleted file mode 100755 index e9e37eb7..00000000 --- a/syntaxes/gitlab-ci.tmLanguage.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", - "name": "GitLab CI", - "scopeName": "source.yaml.gitlab-ci", - "patterns": [ - { - "include": "#comments" - }, - { - "include": "#gitlab-ci-keywords" - }, - { - "include": "#gitlab-ci-sections" - }, - { - "include": "#gitlab-ci-components" - }, - { - "include": "source.yaml" - } - ], - "repository": { - "comments": { - "match": "(?:^| )#.*$", - "name": "comment.line.number-sign.gitlab-ci" - }, - "gitlab-ci-keywords": { - "match": "\\b(image|services|stages|types|before_script|after_script|variables|cache|artifacts)\\b\\s*:", - "captures": { - "1": { - "name": "keyword.control.gitlab-ci" - } - } - }, - "gitlab-ci-sections": { - "match": "^\\s*(include|extends|workflow|default)\\s*:", - "captures": { - "1": { - "name": "entity.name.section.gitlab-ci" - } - } - }, - "gitlab-ci-components": { - "begin": "(\\b\\w+-component\\b)\\s*:", - "beginCaptures": { - "1": { - "name": "entity.name.component.gitlab-ci" - } - }, - "end": "^(?!\\s)", - "patterns": [ - { - "match": "\\s+(\\w+)\\s*:", - "captures": { - "1": { - "name": "variable.parameter.component.gitlab-ci" - } - } - } - ] - }, - "job-definition": { - "match": "^\\s*([\\w\\-\\.]+)\\s*:", - "captures": { - "1": { - "name": "entity.name.job.gitlab-ci" - } - } - }, - "job-keywords": { - "match": "\\s+(script|stage|only|except|tags|allow_failure|when|dependencies|environment|coverage|retry|parallel)\\s*:", - "captures": { - "1": { - "name": "keyword.other.job.gitlab-ci" - } - } - } - } -} diff --git a/test-example.gitlab-ci.yml b/test-example.gitlab-ci.yml deleted file mode 100644 index 1cf7c29e..00000000 --- a/test-example.gitlab-ci.yml +++ /dev/null @@ -1,26 +0,0 @@ -stages: - - test - - deploy - -# Test job -test-job: - stage: test - script: - - echo "Running tests" - include: - - component: some-component - # Type after "component: " to see the debug output and completions - # This is a placeholder for the component URL, replace with actual component URL - # Example: https://gitlab.com/components/some-component@main - # The component should be a valid GitLab component that provides the necessary functionality. - -# Deploy job with existing component -deploy-job: - stage: deploy - script: - - echo "Deploying" - include: - - component: https://gitlab.com/components/opentofu/terraform-plan@main - inputs: - working_directory: "./terraform" - terraform_version: "1.5.0" diff --git a/tests/extension-host/suite/duplicateIncludeCompletion.test.ts b/tests/extension-host/suite/duplicateIncludeCompletion.test.ts new file mode 100644 index 00000000..c9451a7e --- /dev/null +++ b/tests/extension-host/suite/duplicateIncludeCompletion.test.ts @@ -0,0 +1,64 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +const EXTENSION_ID = 'eFAILution.gitlab-component-helper'; + +// __dirname is /out-test/suite at runtime; fixtures live under tests/fixtures. +const FIXTURE_DIR = path.resolve(__dirname, '..', '..', 'tests', 'fixtures', 'duplicate-include-completion'); +const FIXTURE = path.join(FIXTURE_DIR, '.gitlab-ci.yml'); + +async function ensureActive(): Promise { + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext); + if (!ext.isActive) await ext.activate(); +} + +/** + * The input-name completion slot under the second include: the blank, six-space-indented line directly after the + * second include's `region: eu-west-2`. Derived from the document so the position survives fixture edits. + */ +function secondIncludeSlot(doc: vscode.TextDocument): vscode.Position { + const lines = doc.getText().split('\n'); + let seen = 0; + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes('region: eu-west-2')) { + seen++; + if (seen === 1) { + const slotLine = i + 1; + assert.ok(lines[slotLine] !== undefined, 'fixture missing the blank slot line after the second include'); + return new vscode.Position(slotLine, 6); // align with the existing input keys + } + } + } + throw new Error('fixture missing the second include`s region: eu-west-2 input'); +} + +// The fixture includes the same local template twice. The duplicate-include fix made the completion provider +// anchor each include to its own occurrence's line; before it, the second include's inputs slot resolved to the +// first include and offered nothing. This drives the real CompletionProvider end-to-end. +suite('Duplicate include input completions', () => { + suiteSetup(ensureActive); + + test('offers input completions in the second identical include`s slot', async () => { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)); + await vscode.window.showTextDocument(doc); + const slot = secondIncludeSlot(doc); + + const list = await vscode.commands.executeCommand( + 'vscode.executeCompletionItemProvider', + doc.uri, + slot + ); + const labels = (list?.items ?? []).map((i) => (typeof i.label === 'string' ? i.label : i.label.label)); + + // The template's inputs are job_name, cluster, region. `region` is already set under the second include, + // so it must be filtered out; the other two must be offered. + assert.ok(labels.includes('job_name'), `expected job_name to be offered. Got: ${JSON.stringify(labels)}`); + assert.ok(labels.includes('cluster'), `expected cluster to be offered. Got: ${JSON.stringify(labels)}`); + assert.ok( + !labels.includes('region'), + `region is already present under the second include and must be filtered out. Got: ${JSON.stringify(labels)}` + ); + }); +}); diff --git a/tests/extension-host/suite/enumOptionsCompletion.test.ts b/tests/extension-host/suite/enumOptionsCompletion.test.ts new file mode 100644 index 00000000..f790b051 --- /dev/null +++ b/tests/extension-host/suite/enumOptionsCompletion.test.ts @@ -0,0 +1,107 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +const EXTENSION_ID = 'eFAILution.gitlab-component-helper'; + +// __dirname is /out-test/suite at runtime; fixtures live under tests/fixtures. +const FIXTURE_DIR = path.resolve(__dirname, '..', '..', 'tests', 'fixtures', 'enum-options'); +const FIXTURE = path.join(FIXTURE_DIR, '.gitlab-ci.yml'); + +async function ensureActive(): Promise { + const ext = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(ext); + if (!ext.isActive) await ext.activate(); +} + +/** 0-indexed line whose trimmed text starts with `:`. */ +function lineStartingWith(doc: vscode.TextDocument, name: string): number { + const lines = doc.getText().split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim().startsWith(`${name}:`)) return i; + } + throw new Error(`fixture missing a line starting with ${name}:`); +} + +function labels(list: vscode.CompletionList | undefined): string[] { + return (list?.items ?? []).map((i) => (typeof i.label === 'string' ? i.label : i.label.label)); +} + +function itemNamed(list: vscode.CompletionList | undefined, label: string): vscode.CompletionItem | undefined { + return (list?.items ?? []).find((i) => (typeof i.label === 'string' ? i.label : i.label.label) === label); +} + +/** Like {@link itemNamed} but fails the test (and narrows away `undefined`) when no item has that label. */ +function requireItem(list: vscode.CompletionList, label: string): vscode.CompletionItem { + const item = itemNamed(list, label); + if (!item) { + throw new assert.AssertionError({ + message: `expected a '${label}' completion item. Got: ${JSON.stringify(labels(list))}`, + }); + } + return item; +} + +function snippetText(item: vscode.CompletionItem): string { + const insert = item.insertText; + if (typeof insert === 'string') return insert; + if (insert instanceof vscode.SnippetString) return insert.value; + return ''; +} + +async function completionsAt(doc: vscode.TextDocument, position: vscode.Position): Promise { + const list = await vscode.commands.executeCommand( + 'vscode.executeCompletionItemProvider', + doc.uri, + position + ); + assert.ok(list, 'executeCompletionItemProvider returned no completion list'); + return list; +} + +// Drives VS Code's own completion engine (executeCompletionItemProvider) against a local include whose +// `environment` input declares `options:` AND a `default:`. Covers both completion slots: the value position +// (after `environment:`) must offer the allowed values directly, and the name slot must still insert the +// name + value-choice snippet. +suite('Enum options completion for an include input', () => { + suiteSetup(ensureActive); + + test('value slot after `region:` offers the allowed values as bare scalars', async () => { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)); + await vscode.window.showTextDocument(doc); + + const line = lineStartingWith(doc, 'region'); + // End of the ` region: ` line — the value position a user fills. + const position = new vscode.Position(line, doc.lineAt(line).text.length); + const list = await completionsAt(doc, position); + + const eu = requireItem(list, 'eu-west-1'); + const us = requireItem(list, 'us-east-1'); + // Values insert as the bare scalar, not a name: value snippet. + assert.strictEqual(snippetText(eu), 'eu-west-1'); + assert.strictEqual(snippetText(us), 'us-east-1'); + }); + + test('name slot offers the ${1|...|} value choice for an enum input that also has a default (default first)', async () => { + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)); + await vscode.window.showTextDocument(doc); + + // `environment` is an enum input WITH a default (`staging`) and isn't set yet. Accepting it by name must offer + // the value choice — not silently insert the bare default — with the default floated to the front. + const keyIndent = doc.lineAt(lineStartingWith(doc, 'job_name')).firstNonWhitespaceCharacterIndex; + let slotLine = -1; + for (let i = 0; i < doc.lineCount; i++) { + const t = doc.lineAt(i).text; + if (t.trim() === '' && t.length >= keyIndent && /^\s+$/.test(t)) { + slotLine = i; + break; + } + } + assert.ok(slotLine !== -1, 'fixture is missing a whitespace-only name slot at the input-key indent'); + const position = new vscode.Position(slotLine, keyIndent); + const list = await completionsAt(doc, position); + + const envItem = requireItem(list, 'environment'); + assert.strictEqual(snippetText(envItem), 'environment: ${1|staging,production|}'); + }); +}); diff --git a/tests/extension-host/suite/hoverProvider.test.ts b/tests/extension-host/suite/hoverProvider.test.ts index 597db7bf..0d657562 100644 --- a/tests/extension-host/suite/hoverProvider.test.ts +++ b/tests/extension-host/suite/hoverProvider.test.ts @@ -4,13 +4,16 @@ import * as vscode from 'vscode'; const EXTENSION_ID = 'eFAILution.gitlab-component-helper'; // __dirname is /out-test/suite at runtime; fixture lives under tests/fixtures. +// The file is named `.gitlab-ci.yml` (inside a `variables/` subdir) so it matches the +// path-based GitLab CI glob — the providers now key off the `yaml` language plus filename. const FIXTURE = path.resolve( __dirname, '..', '..', 'tests', 'fixtures', - 'variables.gitlab-ci.yml' + 'variables', + '.gitlab-ci.yml' ); async function ensureActive(): Promise { diff --git a/tests/extension-host/tsconfig.json b/tests/extension-host/tsconfig.json new file mode 100644 index 00000000..36781645 --- /dev/null +++ b/tests/extension-host/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.test.json" +} diff --git a/tests/fixtures/duplicate-include-completion/.gitlab-ci.yml b/tests/fixtures/duplicate-include-completion/.gitlab-ci.yml new file mode 100644 index 00000000..fec870e1 --- /dev/null +++ b/tests/fixtures/duplicate-include-completion/.gitlab-ci.yml @@ -0,0 +1,16 @@ +include: + # First include of the deploy template — fully populated. + - local: "duplicate-include-completion/templates/deploy.yml" + inputs: + job_name: deploy:first + cluster: first-cluster + region: eu-west-1 + + # Second include of the SAME template. `region` is already set; the blank, + # six-space-indented slot below is where input-name completions must fire. + # Before the duplicate-include fix this slot resolved to the first include + # and returned nothing. + - local: "duplicate-include-completion/templates/deploy.yml" + inputs: + region: eu-west-2 + diff --git a/tests/fixtures/duplicate-include-completion/templates/deploy.yml b/tests/fixtures/duplicate-include-completion/templates/deploy.yml new file mode 100644 index 00000000..92572b2d --- /dev/null +++ b/tests/fixtures/duplicate-include-completion/templates/deploy.yml @@ -0,0 +1,16 @@ +spec: + inputs: + job_name: + description: The name of the CI job + type: string + cluster: + description: The target cluster (no default — required) + type: string + region: + description: The deployment region + type: string + default: eu-west-1 +--- +$[[ inputs.job_name ]]: + script: + - echo "deploying to $[[ inputs.cluster ]] in $[[ inputs.region ]]" diff --git a/tests/fixtures/enum-options/.gitlab-ci.yml b/tests/fixtures/enum-options/.gitlab-ci.yml new file mode 100644 index 00000000..2208ee34 --- /dev/null +++ b/tests/fixtures/enum-options/.gitlab-ci.yml @@ -0,0 +1,6 @@ +include: + - local: "enum-options/templates/deploy.yml" + inputs: + job_name: deploy + region: + diff --git a/tests/fixtures/enum-options/templates/deploy.yml b/tests/fixtures/enum-options/templates/deploy.yml new file mode 100644 index 00000000..45d4890b --- /dev/null +++ b/tests/fixtures/enum-options/templates/deploy.yml @@ -0,0 +1,22 @@ +spec: + inputs: + environment: + description: Target environment + type: string + default: staging + options: + - staging + - production + region: + description: Target region (no default) + type: string + options: + - eu-west-1 + - us-east-1 + job_name: + description: The name of the CI job + type: string +--- +$[[ inputs.job_name ]]: + script: + - echo "deploy $[[ inputs.job_name ]] to $[[ inputs.environment ]]/$[[ inputs.region ]]" diff --git a/tests/fixtures/variables.gitlab-ci.yml b/tests/fixtures/variables/.gitlab-ci.yml similarity index 100% rename from tests/fixtures/variables.gitlab-ci.yml rename to tests/fixtures/variables/.gitlab-ci.yml diff --git a/tests/unit/GitLabSpecParser.test.ts b/tests/unit/GitLabSpecParser.test.ts index e8eda311..5ec18bdc 100644 --- a/tests/unit/GitLabSpecParser.test.ts +++ b/tests/unit/GitLabSpecParser.test.ts @@ -59,6 +59,60 @@ deploy-job: } }); + test('maps hyphenated input names correctly across comments and blank lines (issue #211)', () => { + // Hyphenated keys (e.g. `job-name`) weren't recognised as new inputs, so their description/default + // bled onto the previous non-hyphenated input — `architecture` would show a later input's description. + // Comment and blank lines within the inputs block must not shift the mapping either. + const template = `spec: + inputs: + job-name: + description: The job name + default: build + # ── package metadata ────────────── + package-name: + description: The package name + + package-version: + description: The version to stamp + default: 0.0.1 + + # ── build options ───────────────── + architecture: + description: Target CPU architecture + default: amd64 + skip-find-images: + description: Skip image discovery + default: false +--- +$[[ inputs.job-name ]]: + script: echo build`; + + const parsed = GitLabSpecParser.parse(template); + const byName = Object.fromEntries(parsed.variables.map((v) => [v.name, v])); + + // All five inputs are recognised — the hyphenated ones were previously skipped entirely. + assert.deepStrictEqual(parsed.variables.map((v) => v.name), [ + 'job-name', + 'package-name', + 'package-version', + 'architecture', + 'skip-find-images', + ]); + + // Each input keeps its OWN description/default — no bleed across the comment/blank boundaries. + assert.strictEqual(byName['job-name'].description, 'The job name'); + assert.strictEqual(byName['job-name'].default, 'build'); + assert.strictEqual(byName['package-version'].description, 'The version to stamp'); + assert.strictEqual(byName['package-version'].default, '0.0.1'); + assert.strictEqual(byName['architecture'].description, 'Target CPU architecture'); + assert.strictEqual(byName['architecture'].default, 'amd64'); + assert.strictEqual(byName['skip-find-images'].description, 'Skip image discovery'); + + // A hyphenated input with no default is marked required, same as a non-hyphenated one. + assert.strictEqual(byName['package-name'].default, undefined); + assert.strictEqual(byName['package-name'].required, true); + }); + test('component without --- separator (legacy format) still scopes inputs to the spec section', () => { const template = `spec: inputs: diff --git a/tests/unit/completionInputContext.test.ts b/tests/unit/completionInputContext.test.ts index 1f0b54e2..3ccfad7c 100644 --- a/tests/unit/completionInputContext.test.ts +++ b/tests/unit/completionInputContext.test.ts @@ -29,6 +29,7 @@ suite('findCompletionInputContextAtLine', () => { assert.deepStrictEqual(ctx, { componentUrl: FULL_PIPELINE_URL, includeKind: 'component', + slot: 'name', existingInputNames: ['environment'], }); }); @@ -46,6 +47,7 @@ suite('findCompletionInputContextAtLine', () => { assert.deepStrictEqual(ctx, { componentUrl: FULL_PIPELINE_URL, includeKind: 'component', + slot: 'name', existingInputNames: ['tags'], }); }); @@ -82,6 +84,7 @@ suite('findCompletionInputContextAtLine', () => { assert.deepStrictEqual(ctx, { componentUrl: FULL_PIPELINE_URL, includeKind: 'component', + slot: 'name', existingInputNames: ['environment'], }); }); @@ -96,6 +99,7 @@ suite('findCompletionInputContextAtLine', () => { assert.deepStrictEqual(ctx, { componentUrl: FULL_PIPELINE_URL, includeKind: 'component', + slot: 'name', existingInputNames: [], }); }); @@ -161,6 +165,7 @@ stages: assert.deepStrictEqual(ctx, { componentUrl: FULL_PIPELINE_URL, includeKind: 'component', + slot: 'name', existingInputNames: ['environment'], }); }); @@ -190,6 +195,25 @@ stages: assert.deepStrictEqual(ctx, { componentUrl: second, includeKind: 'component', + slot: 'name', + existingInputNames: ['stage'], + }); + }); + + test('scopes to the second of two identical include', () => { + const text = `include: + - component: ${FULL_PIPELINE_URL} + inputs: + environment: "dev" + - component: ${FULL_PIPELINE_URL} + inputs: + stage: build + `; + const ctx = findCompletionInputContextAtLine(text, 7); // slot under the second (duplicate) include + assert.deepStrictEqual(ctx, { + componentUrl: FULL_PIPELINE_URL, + includeKind: 'component', + slot: 'name', existingInputNames: ['stage'], }); }); @@ -204,6 +228,7 @@ stages: assert.deepStrictEqual(ctx, { componentUrl: '/templates/nx-test.yml', includeKind: 'local', + slot: 'name', existingInputNames: ['stage'], }); }); @@ -219,6 +244,7 @@ stages: assert.deepStrictEqual(ctx, { componentUrl: FULL_PIPELINE_URL, includeKind: 'component', + slot: 'name', existingInputNames: ['environment'], }); }); @@ -234,10 +260,47 @@ stages: assert.deepStrictEqual(ctx, { componentUrl: FULL_PIPELINE_URL, includeKind: 'component', + slot: 'name', existingInputNames: ['environment'], }); }); + test('detects a value slot when the cursor sits past the colon of a known input line', () => { + const text = `include: + - component: ${FULL_PIPELINE_URL} + inputs: + environment: `; + // Column 19 is just past ` environment:` (the colon is at column 17, the space at 18). + const ctx = findCompletionInputContextAtLine(text, 3, 19); + assert.deepStrictEqual(ctx, { + componentUrl: FULL_PIPELINE_URL, + includeKind: 'component', + slot: 'value', + existingInputNames: ['environment'], + inputName: 'environment', + }); + }); + + test('detects a value slot mid-value while a partial value is being typed', () => { + const text = `include: + - component: ${FULL_PIPELINE_URL} + inputs: + environment: prod`; + const ctx = findCompletionInputContextAtLine(text, 3, 21); + assert.strictEqual(ctx?.slot, 'value'); + assert.strictEqual(ctx?.inputName, 'environment'); + }); + + test('stays a name slot when the cursor sits left of the colon on a key line', () => { + const text = `include: + - component: ${FULL_PIPELINE_URL} + inputs: + environment: `; + // Column 10 is within the key `environment`, before the colon — still a name slot. + const ctx = findCompletionInputContextAtLine(text, 3, 10); + assert.strictEqual(ctx?.slot, 'name'); + }); + test('returns null when the cursor is on a complete key: value line', () => { const text = `include: - component: ${FULL_PIPELINE_URL} @@ -330,6 +393,24 @@ suite('buildInputInsertValue', () => { assert.strictEqual(buildInputInsertValue({ ...base, options: ['true', 'aws'] }), '${1|"true",aws|}'); }); + test('offers the options choice (not the bare default) when an input has both, with the default pre-selected first', () => { + // options + default: the choice wins so the alternatives stay reachable, and the default floats to the front. + assert.strictEqual( + buildInputInsertValue({ ...base, default: 'gcp', options: ['aws', 'gcp', 'azure'] }), + '${1|gcp,aws,azure|}' + ); + // A default that isn't among the options leaves the declared order untouched. + assert.strictEqual( + buildInputInsertValue({ ...base, default: 'on-prem', options: ['aws', 'gcp'] }), + '${1|aws,gcp|}' + ); + // Non-string scalar default still matches its option entry. + assert.strictEqual( + buildInputInsertValue({ ...base, type: 'boolean', default: true, options: [false, true] }), + '${1|true,false|}' + ); + }); + test('falls back to a TODO placeholder for a required untyped input', () => { assert.strictEqual(buildInputInsertValue({ ...base, required: true }), '${1:TODO set value}'); assert.strictEqual(buildInputInsertValue({ ...base, required: false }), '${1:}'); diff --git a/tests/unit/gitlabCiFileMatcherCore.test.ts b/tests/unit/gitlabCiFileMatcherCore.test.ts index e8feda13..4b7c134f 100644 --- a/tests/unit/gitlabCiFileMatcherCore.test.ts +++ b/tests/unit/gitlabCiFileMatcherCore.test.ts @@ -46,18 +46,19 @@ suite('buildFileGlobs', () => { }); suite('matchesGitLabCIFile — language-id escape hatches', () => { - test('returns true for `gitlab-ci` regardless of filename', () => { - assert.strictEqual(matchesGitLabCIFile('repo/anything.txt', 'gitlab-ci', buildFileGlobs()), true); - }); - test('returns true for `shellscript` regardless of filename', () => { assert.strictEqual(matchesGitLabCIFile('repo/some-script.sh', 'shellscript', buildFileGlobs()), true); // Untitled documents have non-filesystem-shaped URIs; the language-id branch must still win. assert.strictEqual(matchesGitLabCIFile('untitled:Untitled-1', 'shellscript', buildFileGlobs()), true); }); + test('does not treat `gitlab-ci` as a privileged language id (custom language removed in #117)', () => { + // GitLab CI files now keep the `yaml` language id and are matched by path, not language. + assert.ok(!ALLOWED_LANGUAGE_IDS.has('gitlab-ci')); + assert.strictEqual(matchesGitLabCIFile('repo/anything.txt', 'gitlab-ci', buildFileGlobs()), false); + }); + test('exposes the allowed-language-ids set for downstream callers', () => { - assert.ok(ALLOWED_LANGUAGE_IDS.has('gitlab-ci')); assert.ok(ALLOWED_LANGUAGE_IDS.has('shellscript')); assert.ok(!ALLOWED_LANGUAGE_IDS.has('yaml')); }); @@ -69,6 +70,11 @@ suite('matchesGitLabCIFile — default globs (yaml language)', () => { { path: 'repo/.gitlab-ci.yml', label: 'canonical .yml in subdir' }, { path: 'repo/.gitlab-ci.yaml', label: 'canonical .yaml in subdir' }, { path: '.gitlab-ci.yml', label: 'root canonical' }, + // Suffix convention (`*.gitlab-ci.{yml,yaml}`) — restores what the removed custom language matched via + // its `extensions`. Regression guard for files like `deploy.gitlab-ci.yml` losing hover/completion. + { path: 'deploy.gitlab-ci.yml', label: 'suffix-named at root' }, + { path: 'repo/templates/component.gitlab-ci.yml', label: 'suffix-named in subdir' }, + { path: 'repo/test-example.gitlab-ci.yaml', label: 'suffix-named .yaml' }, { path: 'repo/.gitlab/ci/build.yml', label: '.gitlab/ nested .yml' }, { path: 'repo/.gitlab/pipelines/deploy.yaml', label: '.gitlab/ deep .yaml' }, ]; @@ -85,6 +91,9 @@ suite('matchesGitLabCIFile — default globs (yaml language)', () => { // Regression guard: validation must not fire on arbitrary YAML — only files matching the defaults. { path: 'repo/docker-compose.yml', label: 'plain YAML with no matching glob' }, { path: 'repo/kustomize/base.yaml', label: 'arbitrary YAML in unrelated subdir' }, + // Guard: the suffix glob must anchor on `.gitlab-ci.{yml,yaml}` — a file that merely contains + // "gitlab-ci" mid-name but ends differently must not match. + { path: 'repo/my-gitlab-ci-notes.yml', label: 'contains gitlab-ci but wrong suffix' }, ]; for (const c of negativeCases) { test(`rejects: ${c.label} (${c.path})`, () => { diff --git a/tests/unit/hoverInputContext.test.ts b/tests/unit/hoverInputContext.test.ts index ff1090ad..c27528d0 100644 --- a/tests/unit/hoverInputContext.test.ts +++ b/tests/unit/hoverInputContext.test.ts @@ -80,6 +80,22 @@ stages: }); }); + test('resolves an input under the second of two identical includes', () => { + const text = `include: + - component: ${FULL_PIPELINE_URL} + inputs: + environment: "dev" + - component: ${FULL_PIPELINE_URL} + inputs: + stage: build`; + const ctx = findInputContextAtLine(text, 6); // `stage:` under the second include + assert.deepStrictEqual(ctx, { + inputName: 'stage', + componentUrl: FULL_PIPELINE_URL, + includeKind: 'component', + }); + }); + test('returns null when the line is in a sibling `variables:` block, not `inputs:`', () => { const text = `include: - component: https://gitlab.com/components/test@1.0.0 diff --git a/tests/unit/includeMatcher.test.ts b/tests/unit/includeMatcher.test.ts index a4eefe97..b157496e 100644 --- a/tests/unit/includeMatcher.test.ts +++ b/tests/unit/includeMatcher.test.ts @@ -14,6 +14,7 @@ import { isLocalInclude, includeKeyAndUrl, includeLineMatches, + findIncludeLine, } from '../../src/utils/includeMatcher'; suite('includeMatcher — isIncludeEntry', () => { @@ -96,3 +97,35 @@ suite('includeMatcher — includeLineMatches token boundary', () => { assert.equal(includeLineMatches(' - local: templates/deploy', 'local:', localUrl), true); }); }); + +suite('includeMatcher — findIncludeLine occurrence disambiguation', () => { + const url = 'host/g/c@1'; + const lines = [ + 'include:', + ` - component: ${url}`, + ' inputs:', + ' a: 1', + ` - component: ${url}`, + ' inputs:', + ' b: 2', + ]; + + test('defaults to the first occurrence', () => { + assert.equal(findIncludeLine(lines, 'component:', url), 1); + }); + + test('returns the Nth occurrence for duplicate key+URL includes', () => { + assert.equal(findIncludeLine(lines, 'component:', url, 1), 1); + assert.equal(findIncludeLine(lines, 'component:', url, 2), 4); + }); + + test('returns -1 when fewer than `occurrence` lines match', () => { + assert.equal(findIncludeLine(lines, 'component:', url, 3), -1); + assert.equal(findIncludeLine(lines, 'component:', 'host/g/absent@1'), -1); + }); + + test('honours the token boundary, not a prefix match', () => { + const collide = [' - component: host/g/c@10', ' - component: host/g/c@1']; + assert.equal(findIncludeLine(collide, 'component:', 'host/g/c@1', 1), 1); + }); +}); diff --git a/tests/unit/redirectPolicy.test.ts b/tests/unit/redirectPolicy.test.ts new file mode 100644 index 00000000..e500ff32 --- /dev/null +++ b/tests/unit/redirectPolicy.test.ts @@ -0,0 +1,124 @@ +// @mocha +/** + * Tests for the credential-safe redirect policy the HTTP client applies before following a `Location`. + * The security-critical guarantees are: the user's GitLab token (`PRIVATE-TOKEN`/`Authorization`) is never + * replayed to a different origin, HTTPS is never silently downgraded to HTTP, and only genuine redirect + * statuses are followed. These protect against token exfiltration when a moved component project's old + * path is reclaimed by a third party. + */ + +import * as assert from 'node:assert/strict'; +import { NetworkError } from '../../src/errors/types'; +import { + planRedirect, + stripCredentialHeaders, + REDIRECT_STATUS_CODES, +} from '../../src/utils/redirectPolicy'; + +const BASE = 'https://gitlab.com/api/v4/projects/foo%2Fbar'; + +suite('planRedirect: non-redirect responses', () => { + test('returns null for a 2xx status', () => { + assert.equal(planRedirect(BASE, 200, undefined), null); + }); + + test('returns null for a 4xx status', () => { + assert.equal(planRedirect(BASE, 404, 'https://gitlab.com/elsewhere'), null); + }); + + test('returns null for 304 Not Modified (a 3xx that is not a redirect)', () => { + assert.equal(planRedirect(BASE, 304, undefined), null); + }); +}); + +suite('planRedirect: same-origin redirects keep credentials', () => { + for (const status of REDIRECT_STATUS_CODES) { + test(`HTTP ${status} to an absolute same-origin URL is followed without stripping`, () => { + const plan = planRedirect(BASE, status, 'https://gitlab.com/api/v4/projects/newns%2Fbar'); + assert.ok(plan); + assert.equal(plan.nextUrl, 'https://gitlab.com/api/v4/projects/newns%2Fbar'); + assert.equal(plan.stripCredentials, false); + }); + } + + test('a relative Location resolves against the current URL and stays same-origin', () => { + const plan = planRedirect(BASE, 302, '/api/v4/projects/999'); + assert.ok(plan); + assert.equal(plan.nextUrl, 'https://gitlab.com/api/v4/projects/999'); + assert.equal(plan.stripCredentials, false); + }); + + test('same host on a non-default port is still same-origin when the port matches', () => { + const plan = planRedirect('https://gitlab.example.com:8443/a', 302, 'https://gitlab.example.com:8443/b'); + assert.ok(plan); + assert.equal(plan.stripCredentials, false); + }); +}); + +suite('planRedirect: cross-origin redirects strip credentials', () => { + test('a different host is cross-origin', () => { + const plan = planRedirect(BASE, 302, 'https://attacker.example/api/v4/projects/foo%2Fbar'); + assert.ok(plan); + assert.equal(plan.nextUrl, 'https://attacker.example/api/v4/projects/foo%2Fbar'); + assert.equal(plan.stripCredentials, true); + }); + + test('a different port on the same host is cross-origin', () => { + const plan = planRedirect('https://gitlab.example.com/a', 302, 'https://gitlab.example.com:9999/a'); + assert.ok(plan); + assert.equal(plan.stripCredentials, true); + }); + + test('an HTTP→HTTPS upgrade to the same host is cross-origin (scheme differs) so credentials are stripped', () => { + const plan = planRedirect('http://gitlab.example.com/a', 302, 'https://gitlab.example.com/a'); + assert.ok(plan); + assert.equal(plan.stripCredentials, true); + }); +}); + +suite('planRedirect: malformed and unsafe redirects throw', () => { + test('a redirect status with no Location throws', () => { + assert.throws(() => planRedirect(BASE, 302, undefined), NetworkError); + }); + + test('a redirect status with an empty Location throws', () => { + assert.throws(() => planRedirect(BASE, 301, ' '), NetworkError); + }); + + test('an HTTPS→HTTP downgrade is refused', () => { + assert.throws( + () => planRedirect(BASE, 302, 'http://gitlab.com/api/v4/projects/foo%2Fbar'), + NetworkError + ); + }); +}); + +suite('stripCredentialHeaders', () => { + test('removes Authorization, PRIVATE-TOKEN, and Cookie', () => { + const stripped = stripCredentialHeaders({ + 'User-Agent': 'VSCode-GitLabComponentHelper', + 'PRIVATE-TOKEN': 'glpat-secret', + Authorization: 'Bearer secret', + Cookie: '_gitlab_session=abc', + Accept: 'application/json', + }); + assert.deepEqual(stripped, { + 'User-Agent': 'VSCode-GitLabComponentHelper', + Accept: 'application/json', + }); + }); + + test('matches credential header names case-insensitively', () => { + const stripped = stripCredentialHeaders({ + 'private-token': 'glpat-secret', + authorization: 'Bearer secret', + 'X-Keep': 'yes', + }); + assert.deepEqual(stripped, { 'X-Keep': 'yes' }); + }); + + test('returns an equivalent map when there is nothing to strip', () => { + const headers = { 'User-Agent': 'VSCode-GitLabComponentHelper' }; + assert.deepEqual(stripCredentialHeaders(headers), headers); + }); +}); diff --git a/tests/validate-optimizations.js b/tests/validate-optimizations.js index 02283181..196ab67c 100644 --- a/tests/validate-optimizations.js +++ b/tests/validate-optimizations.js @@ -102,20 +102,6 @@ try { } }); - // Check documentation - console.log('\n--- Documentation ---'); - const docsPath = path.join(__dirname, '../PERFORMANCE_OPTIMIZATIONS.md'); - if (fs.existsSync(docsPath)) { - const docsContent = fs.readFileSync(docsPath, 'utf8'); - if (docsContent.length > 1000) { - console.log('✅ Performance documentation: Complete'); - } else { - console.log('⚠️ Performance documentation: Basic'); - } - } else { - console.log('❌ Performance documentation: Missing'); - } - console.log('\n=== Validation Complete ==='); // Overall success criteria diff --git a/tsconfig.tests.json b/tsconfig.tests.json index 993d8fc1..046fea1c 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -3,8 +3,20 @@ "compilerOptions": { "noEmit": true, "rootDir": ".", - "types": ["node", "mocha"] + "types": [ + "node", + "mocha" + ] }, - "include": ["src/**/*", "tests/unit/**/*.ts"], - "exclude": ["node_modules", ".vscode-test", "out", "out-test", "tests/extension-host"] + "include": [ + "src/**/*", + "tests/unit/**/*.ts" + ], + "exclude": [ + "node_modules", + ".vscode-test", + "out", + "out-test", + "tests/extension-host" + ] }